{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "slug": "scroll-text",
  "name": "Scroll Text",
  "title": "Scroll Text",
  "type": "registry:ui",
  "description": "A smooth, reversible scroll-driven text reveal with Fade, ASCII, and Mosaic effects.",
  "summary": "Reveal words through fading, resolving ASCII symbols, or dissolving mosaic blocks. Tune alignment, typography, color, opacity, stagger, smoothing, rise, and blur in Component Studio.",
  "category": "backgrounds-and-effects",
  "tags": [
    "text",
    "scroll",
    "reveal",
    "typography",
    "gsap",
    "scrolltrigger",
    "words"
  ],
  "dependencies": [
    "gsap",
    "clsx",
    "tailwind-merge"
  ],
  "version": "1.0.0",
  "createdDate": "2026-10-10",
  "updatedDate": "2026-10-10",
  "interactive": true,
  "supportsColor": false,
  "highlights": [
    "Fade, ASCII, and Mosaic reveals that unwind when scrolling back",
    "Optional accent words, rise, and blur",
    "Native page scrolling or a supplied scroll container",
    "Live effect, typography, alignment, and motion controls",
    "Fully readable server-rendered and reduced-motion fallback",
    "Scoped GSAP and ResizeObserver cleanup",
    "Initializes after native scroll-container refs are attached"
  ],
  "anatomy": [
    "<ScrollText> semantic paragraph with the full text rendered once in an sr-only span",
    "Whitespace-preserving visual word spans marked aria-hidden",
    "A shared staggered timeline connected to one ScrollTrigger"
  ],
  "physics": {
    "engine": "GSAP + ScrollTrigger",
    "description": "One staggered timeline maps scroll progress to word opacity and optional transforms. ASCII symbols resolve within fixed letter boxes; Mosaic tiles dissolve over the original words. Numeric scrub smooths both directions. A debounced ResizeObserver refreshes layout changes.",
    "parameters": [
      {
        "label": "Smoothing",
        "value": "0.6s"
      },
      {
        "label": "Word stagger",
        "value": "0.12"
      },
      {
        "label": "Dimmed opacity",
        "value": "0.16"
      }
    ]
  },
  "accessibility": {
    "role": "paragraph",
    "keyboard": [
      {
        "key": "Tab",
        "description": "Focus the named scroll region in the demo."
      },
      {
        "key": "Arrow Up / Arrow Down / Page Up / Page Down",
        "description": "Scroll the demo using native browser controls."
      }
    ],
    "aria": "The complete paragraph is rendered once as real text in an sr-only span. Animated word spans are aria-hidden to avoid duplicate or scrambled announcements.",
    "reducedMotion": "All words remain fully visible. No scroll animation or blur is created; changing the preference at runtime reverts the animation."
  },
  "guidelines": {
    "recommended": [
      "Editorial introductions and portfolio statements",
      "Scroll-driven product stories and section copy"
    ],
    "bestPractices": [
      "Use a readable foreground and accessible accent colors.",
      "Give the paragraph enough scroll space before and after it to cover the configured range.",
      "Pass a stable ref when using a nested native scroll container.",
      "Keep rise and blur at zero for the reference opacity-only effect.",
      "Use className or style for responsive typography and width."
    ]
  },
  "props": [
    {
      "name": "text",
      "type": "string",
      "description": "The complete paragraph. Whitespace and line breaks are preserved.",
      "required": true
    },
    {
      "name": "highlightWords",
      "type": "readonly string[]",
      "description": "Words to tint with the accent color. Matching ignores case and surrounding punctuation.",
      "defaultValue": "[]"
    },
    {
      "name": "color",
      "type": "string",
      "description": "Text color. Inherits the surrounding foreground by default.",
      "defaultValue": "\"currentColor\""
    },
    {
      "name": "accentColor",
      "type": "string",
      "description": "Color of the highlighted words.",
      "defaultValue": "\"#c9a76a\""
    },
    {
      "name": "dimOpacity",
      "type": "number",
      "description": "Starting word opacity, clamped to 0.05–1.",
      "defaultValue": "0.16"
    },
    {
      "name": "scrub",
      "type": "number",
      "description": "Scroll catch-up duration in seconds, clamped to 0–2. Zero tracks scroll directly.",
      "defaultValue": "0.6"
    },
    {
      "name": "stagger",
      "type": "number",
      "description": "Spacing between word reveals on the shared timeline, clamped to 0.01–1.",
      "defaultValue": "0.12"
    },
    {
      "name": "rise",
      "type": "number",
      "description": "Optional initial vertical offset in pixels, clamped to 0–40.",
      "defaultValue": "0"
    },
    {
      "name": "blur",
      "type": "number",
      "description": "Optional initial blur in pixels, clamped to 0–12. Zero avoids filter animation.",
      "defaultValue": "0"
    },
    {
      "name": "effect",
      "type": "\"fade\" | \"ascii\" | \"mosaic\"",
      "description": "Reveal style. ASCII resolves symbols into letters; Mosaic dissolves a grid of blocks into words. All modes reverse with scroll.",
      "defaultValue": "\"fade\""
    },
    {
      "name": "start",
      "type": "string",
      "description": "ScrollTrigger start position relative to the viewport or supplied scroller.",
      "defaultValue": "\"top 85%\""
    },
    {
      "name": "end",
      "type": "string",
      "description": "ScrollTrigger end position. Leave enough scroll space after the paragraph to reach it.",
      "defaultValue": "\"bottom 40%\""
    },
    {
      "name": "align",
      "type": "\"left\" | \"center\" | \"right\"",
      "description": "Paragraph alignment.",
      "defaultValue": "\"center\""
    },
    {
      "name": "scroller",
      "type": "RefObject<HTMLElement | null>",
      "description": "Optional native scroll container ref. Uses page scrolling when omitted."
    },
    {
      "name": "className",
      "type": "string",
      "description": "Additional paragraph classes, including responsive typography and maximum width."
    },
    {
      "name": "style",
      "type": "CSSProperties",
      "description": "Custom typography, dimensions, and inline paragraph styles."
    }
  ],
  "usage": "import { ScrollText } from \"@/components/ui/scroll-text\";\n\nexport function Introduction() {\n  return (\n    <section className=\"px-6 py-[40vh]\">\n      <ScrollText\n        text=\"Thoughtful motion. Every word, at your pace. Built to make an impression.\"\n        highlightWords={[\"motion\", \"impression\"]}\n        className=\"mx-auto max-w-4xl\"\n        scrub={0.6}\n      />\n    </section>\n  );\n}",
  "files": [
    {
      "name": "scroll-text.tsx",
      "path": "registry/ui/scroll-text.tsx",
      "code": "\"use client\";\n\nimport { useEffect, useRef, type CSSProperties, type RefObject } from \"react\";\nimport gsap from \"gsap\";\nimport { ScrollTrigger } from \"gsap/ScrollTrigger\";\nimport { cn } from \"@/lib/utils\";\n\ngsap.registerPlugin(ScrollTrigger);\n\nexport interface ScrollTextProps {\n  text: string;\n  highlightWords?: readonly string[];\n  color?: string;\n  accentColor?: string;\n  dimOpacity?: number;\n  scrub?: number;\n  stagger?: number;\n  rise?: number;\n  blur?: number;\n  effect?: \"fade\" | \"ascii\" | \"mosaic\";\n  start?: string;\n  end?: string;\n  align?: \"left\" | \"center\" | \"right\";\n  scroller?: RefObject<HTMLElement | null>;\n  className?: string;\n  style?: CSSProperties;\n}\n\nconst bounded = (value: number, min: number, max: number, fallback: number) =>\n  Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;\n\nconst normalizeWord = (word: string) =>\n  word.toLowerCase().replace(/^[\\p{P}\\p{S}]+|[\\p{P}\\p{S}]+$/gu, \"\");\n\nconst ASCII_GLYPHS = \"!<>-_\\\\/[]{}=+*^?#$%&~:;0123456789\";\n\nexport function ScrollText({\n  text,\n  highlightWords = [],\n  color = \"currentColor\",\n  accentColor = \"#c9a76a\",\n  dimOpacity = 0.16,\n  scrub = 0.6,\n  stagger = 0.12,\n  rise = 0,\n  blur = 0,\n  effect = \"fade\",\n  start = \"top 85%\",\n  end = \"bottom 40%\",\n  align = \"center\",\n  scroller,\n  className,\n  style,\n}: ScrollTextProps) {\n  const rootRef = useRef<HTMLParagraphElement>(null);\n  const opacity = bounded(dimOpacity, 0.05, 1, 0.16);\n  const smoothing = bounded(scrub, 0, 2, 0.6);\n  const spacing = bounded(stagger, 0.01, 1, 0.12);\n  const offset = bounded(rise, 0, 40, 0);\n  const softness = bounded(blur, 0, 12, 0);\n  const highlights = new Set(highlightWords.map(normalizeWord));\n\n  useEffect(\n    () => {\n      const root = rootRef.current;\n      if (!root || !text.trim()) return;\n      const words = gsap.utils.toArray<HTMLElement>(\"[data-scroll-word]\", root);\n      const media = gsap.matchMedia();\n\n      media.add(\"(prefers-reduced-motion: no-preference)\", () => {\n        const effects = effect === \"fade\" ? [] : words.map((word, index) => ({\n          progress: 0,\n          previous: -1,\n          index,\n          ink: gsap.utils.toArray<HTMLElement>(\"[data-scroll-ink]\", word),\n          glyphs: gsap.utils.toArray<HTMLElement>(\"[data-scroll-glyph]\", word),\n          tiles: gsap.utils.toArray<HTMLElement>(\"[data-scroll-tile]\", word),\n        }));\n        const renderEffects = () => {\n          effects.forEach((state) => {\n            const progress = state.progress;\n            if (state.previous === progress) return;\n            state.previous = progress;\n\n            if (effect === \"ascii\") {\n              state.ink.forEach((letter, index) => {\n                const reveal = gsap.utils.clamp(\n                  0, 1, progress * (state.ink.length + 1) - index,\n                );\n                const glyph = state.glyphs[index];\n                letter.style.opacity = String(reveal);\n                glyph.style.opacity = String(1 - reveal);\n                const symbolIndex = state.index * 7 + index * 11 + Math.floor(progress * 24);\n                const symbol = ASCII_GLYPHS[symbolIndex % ASCII_GLYPHS.length];\n                if (glyph.textContent !== symbol) glyph.textContent = symbol;\n              });\n            } else {\n              state.ink[0].style.opacity = String(progress);\n              state.tiles.forEach((tile, index) => {\n                const order = ((index * 5 + state.index * 3) % 8) / 8;\n                const dissolve = gsap.utils.clamp(\n                  0, 1, progress * 1.6 - order * 0.6,\n                );\n                tile.style.opacity = String((1 - dissolve) * 0.7);\n                tile.style.transform = `scale(${1 - dissolve * 0.75})`;\n              });\n            }\n          });\n        };\n\n        renderEffects();\n        gsap.set(words, {\n          opacity,\n          y: offset,\n          ...(softness > 0 ? { filter: `blur(${softness}px)` } : {}),\n        });\n        const timeline = gsap.timeline({\n          scrollTrigger: {\n            trigger: root,\n            scroller: scroller?.current ?? undefined,\n            start,\n            end,\n            scrub: smoothing || true,\n            onRefresh: (self) => self.animation?.progress(self.progress),\n          },\n        });\n        timeline.to(words, {\n          opacity: 1,\n          y: 0,\n          ...(softness > 0 ? { filter: \"blur(0px)\" } : {}),\n          duration: 1,\n          stagger: spacing,\n          ease: \"none\",\n        });\n        if (effects.length) {\n          timeline.to(\n            effects,\n            {\n              progress: 1,\n              duration: 1,\n              stagger: spacing,\n              ease: \"none\",\n              onUpdate: renderEffects,\n            },\n            0,\n          );\n        }\n        const refresh = gsap\n          .delayedCall(0.15, () => timeline.scrollTrigger?.refresh())\n          .pause();\n        const observer = new ResizeObserver(() => refresh.restart(true));\n        observer.observe(root);\n        if (scroller?.current) observer.observe(scroller.current);\n\n        return () => {\n          observer.disconnect();\n          refresh.kill();\n          effects.forEach(({ ink, glyphs, tiles }) => {\n            ink.forEach((element) => element.style.removeProperty(\"opacity\"));\n            [...glyphs, ...tiles].forEach((element) => {\n              element.style.opacity = \"0\";\n              element.style.removeProperty(\"transform\");\n            });\n          });\n        };\n      }, root);\n\n      return () => media.revert();\n    },\n    [\n      text,\n      opacity,\n      smoothing,\n      spacing,\n      offset,\n      softness,\n      effect,\n      start,\n      end,\n      scroller,\n    ],\n  );\n\n  return (\n    <p\n      ref={rootRef}\n      className={cn(\n        \"m-0 whitespace-pre-wrap text-[clamp(24px,4vw,48px)] font-medium leading-[1.3] tracking-[-0.035em]\",\n        className,\n      )}\n      style={{ color, textAlign: align, ...style }}\n    >\n      <span className=\"sr-only\">{text}</span>\n      <span aria-hidden=\"true\">\n        {text.split(/(\\s+)/).map((part, index) =>\n          /^\\s*$/.test(part) ? part : (\n            <span\n              key={`${effect}-${index}`}\n              data-scroll-word\n              className=\"relative inline-block max-w-full align-baseline [overflow-wrap:anywhere]\"\n              style={\n                highlights.has(normalizeWord(part))\n                  ? { color: accentColor }\n                  : undefined\n              }\n            >\n              {effect === \"ascii\" ? (\n                Array.from(part).map((letter, letterIndex) => (\n                  <span key={letterIndex} className=\"relative inline-block\">\n                    <span data-scroll-ink>{letter}</span>\n                    <span\n                      data-scroll-glyph\n                      className=\"pointer-events-none absolute inset-0 flex items-center justify-center font-mono\"\n                      style={{ opacity: 0 }}\n                    >\n                      {ASCII_GLYPHS[letterIndex % ASCII_GLYPHS.length]}\n                    </span>\n                  </span>\n                ))\n              ) : effect === \"mosaic\" ? (\n                <>\n                  <span data-scroll-ink>{part}</span>\n                  <span className=\"pointer-events-none absolute inset-x-0 bottom-[18%] top-[18%] grid grid-cols-4 grid-rows-2 gap-px\">\n                    {Array.from({ length: 8 }, (_, tileIndex) => (\n                      <span\n                        key={tileIndex}\n                        data-scroll-tile\n                        className=\"bg-current\"\n                        style={{ opacity: 0 }}\n                      />\n                    ))}\n                  </span>\n                </>\n              ) : part}\n            </span>\n          ),\n        )}\n      </span>\n    </p>\n  );\n}\n\nexport default ScrollText;\n",
      "content": "\"use client\";\n\nimport { useEffect, useRef, type CSSProperties, type RefObject } from \"react\";\nimport gsap from \"gsap\";\nimport { ScrollTrigger } from \"gsap/ScrollTrigger\";\nimport { cn } from \"@/lib/utils\";\n\ngsap.registerPlugin(ScrollTrigger);\n\nexport interface ScrollTextProps {\n  text: string;\n  highlightWords?: readonly string[];\n  color?: string;\n  accentColor?: string;\n  dimOpacity?: number;\n  scrub?: number;\n  stagger?: number;\n  rise?: number;\n  blur?: number;\n  effect?: \"fade\" | \"ascii\" | \"mosaic\";\n  start?: string;\n  end?: string;\n  align?: \"left\" | \"center\" | \"right\";\n  scroller?: RefObject<HTMLElement | null>;\n  className?: string;\n  style?: CSSProperties;\n}\n\nconst bounded = (value: number, min: number, max: number, fallback: number) =>\n  Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;\n\nconst normalizeWord = (word: string) =>\n  word.toLowerCase().replace(/^[\\p{P}\\p{S}]+|[\\p{P}\\p{S}]+$/gu, \"\");\n\nconst ASCII_GLYPHS = \"!<>-_\\\\/[]{}=+*^?#$%&~:;0123456789\";\n\nexport function ScrollText({\n  text,\n  highlightWords = [],\n  color = \"currentColor\",\n  accentColor = \"#c9a76a\",\n  dimOpacity = 0.16,\n  scrub = 0.6,\n  stagger = 0.12,\n  rise = 0,\n  blur = 0,\n  effect = \"fade\",\n  start = \"top 85%\",\n  end = \"bottom 40%\",\n  align = \"center\",\n  scroller,\n  className,\n  style,\n}: ScrollTextProps) {\n  const rootRef = useRef<HTMLParagraphElement>(null);\n  const opacity = bounded(dimOpacity, 0.05, 1, 0.16);\n  const smoothing = bounded(scrub, 0, 2, 0.6);\n  const spacing = bounded(stagger, 0.01, 1, 0.12);\n  const offset = bounded(rise, 0, 40, 0);\n  const softness = bounded(blur, 0, 12, 0);\n  const highlights = new Set(highlightWords.map(normalizeWord));\n\n  useEffect(\n    () => {\n      const root = rootRef.current;\n      if (!root || !text.trim()) return;\n      const words = gsap.utils.toArray<HTMLElement>(\"[data-scroll-word]\", root);\n      const media = gsap.matchMedia();\n\n      media.add(\"(prefers-reduced-motion: no-preference)\", () => {\n        const effects = effect === \"fade\" ? [] : words.map((word, index) => ({\n          progress: 0,\n          previous: -1,\n          index,\n          ink: gsap.utils.toArray<HTMLElement>(\"[data-scroll-ink]\", word),\n          glyphs: gsap.utils.toArray<HTMLElement>(\"[data-scroll-glyph]\", word),\n          tiles: gsap.utils.toArray<HTMLElement>(\"[data-scroll-tile]\", word),\n        }));\n        const renderEffects = () => {\n          effects.forEach((state) => {\n            const progress = state.progress;\n            if (state.previous === progress) return;\n            state.previous = progress;\n\n            if (effect === \"ascii\") {\n              state.ink.forEach((letter, index) => {\n                const reveal = gsap.utils.clamp(\n                  0, 1, progress * (state.ink.length + 1) - index,\n                );\n                const glyph = state.glyphs[index];\n                letter.style.opacity = String(reveal);\n                glyph.style.opacity = String(1 - reveal);\n                const symbolIndex = state.index * 7 + index * 11 + Math.floor(progress * 24);\n                const symbol = ASCII_GLYPHS[symbolIndex % ASCII_GLYPHS.length];\n                if (glyph.textContent !== symbol) glyph.textContent = symbol;\n              });\n            } else {\n              state.ink[0].style.opacity = String(progress);\n              state.tiles.forEach((tile, index) => {\n                const order = ((index * 5 + state.index * 3) % 8) / 8;\n                const dissolve = gsap.utils.clamp(\n                  0, 1, progress * 1.6 - order * 0.6,\n                );\n                tile.style.opacity = String((1 - dissolve) * 0.7);\n                tile.style.transform = `scale(${1 - dissolve * 0.75})`;\n              });\n            }\n          });\n        };\n\n        renderEffects();\n        gsap.set(words, {\n          opacity,\n          y: offset,\n          ...(softness > 0 ? { filter: `blur(${softness}px)` } : {}),\n        });\n        const timeline = gsap.timeline({\n          scrollTrigger: {\n            trigger: root,\n            scroller: scroller?.current ?? undefined,\n            start,\n            end,\n            scrub: smoothing || true,\n            onRefresh: (self) => self.animation?.progress(self.progress),\n          },\n        });\n        timeline.to(words, {\n          opacity: 1,\n          y: 0,\n          ...(softness > 0 ? { filter: \"blur(0px)\" } : {}),\n          duration: 1,\n          stagger: spacing,\n          ease: \"none\",\n        });\n        if (effects.length) {\n          timeline.to(\n            effects,\n            {\n              progress: 1,\n              duration: 1,\n              stagger: spacing,\n              ease: \"none\",\n              onUpdate: renderEffects,\n            },\n            0,\n          );\n        }\n        const refresh = gsap\n          .delayedCall(0.15, () => timeline.scrollTrigger?.refresh())\n          .pause();\n        const observer = new ResizeObserver(() => refresh.restart(true));\n        observer.observe(root);\n        if (scroller?.current) observer.observe(scroller.current);\n\n        return () => {\n          observer.disconnect();\n          refresh.kill();\n          effects.forEach(({ ink, glyphs, tiles }) => {\n            ink.forEach((element) => element.style.removeProperty(\"opacity\"));\n            [...glyphs, ...tiles].forEach((element) => {\n              element.style.opacity = \"0\";\n              element.style.removeProperty(\"transform\");\n            });\n          });\n        };\n      }, root);\n\n      return () => media.revert();\n    },\n    [\n      text,\n      opacity,\n      smoothing,\n      spacing,\n      offset,\n      softness,\n      effect,\n      start,\n      end,\n      scroller,\n    ],\n  );\n\n  return (\n    <p\n      ref={rootRef}\n      className={cn(\n        \"m-0 whitespace-pre-wrap text-[clamp(24px,4vw,48px)] font-medium leading-[1.3] tracking-[-0.035em]\",\n        className,\n      )}\n      style={{ color, textAlign: align, ...style }}\n    >\n      <span className=\"sr-only\">{text}</span>\n      <span aria-hidden=\"true\">\n        {text.split(/(\\s+)/).map((part, index) =>\n          /^\\s*$/.test(part) ? part : (\n            <span\n              key={`${effect}-${index}`}\n              data-scroll-word\n              className=\"relative inline-block max-w-full align-baseline [overflow-wrap:anywhere]\"\n              style={\n                highlights.has(normalizeWord(part))\n                  ? { color: accentColor }\n                  : undefined\n              }\n            >\n              {effect === \"ascii\" ? (\n                Array.from(part).map((letter, letterIndex) => (\n                  <span key={letterIndex} className=\"relative inline-block\">\n                    <span data-scroll-ink>{letter}</span>\n                    <span\n                      data-scroll-glyph\n                      className=\"pointer-events-none absolute inset-0 flex items-center justify-center font-mono\"\n                      style={{ opacity: 0 }}\n                    >\n                      {ASCII_GLYPHS[letterIndex % ASCII_GLYPHS.length]}\n                    </span>\n                  </span>\n                ))\n              ) : effect === \"mosaic\" ? (\n                <>\n                  <span data-scroll-ink>{part}</span>\n                  <span className=\"pointer-events-none absolute inset-x-0 bottom-[18%] top-[18%] grid grid-cols-4 grid-rows-2 gap-px\">\n                    {Array.from({ length: 8 }, (_, tileIndex) => (\n                      <span\n                        key={tileIndex}\n                        data-scroll-tile\n                        className=\"bg-current\"\n                        style={{ opacity: 0 }}\n                      />\n                    ))}\n                  </span>\n                </>\n              ) : part}\n            </span>\n          ),\n        )}\n      </span>\n    </p>\n  );\n}\n\nexport default ScrollText;\n",
      "type": "registry:ui",
      "target": "@ui/scroll-text.tsx"
    }
  ]
}
