{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scroll-text",
  "type": "registry:ui",
  "title": "Scroll Text",
  "description": "A smooth, reversible scroll-driven text reveal with Fade, ASCII, and Mosaic effects.",
  "dependencies": [
    "gsap",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/scroll-text.tsx",
      "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"
    }
  ]
}
