{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "motion-scroll-text",
  "title": "Scroll text",
  "description": "Text that reveals word by word, letter by letter or under a highlight as it scrolls through the viewport.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/use-prefers-reduced-motion.json",
    "https://ui.ndekocode.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "components/core/scroll-text.tsx",
      "content": "'use client';\nimport { cn } from '@/lib/utils';\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport {\n  motion,\n  useScroll,\n  useTransform,\n  type MotionValue,\n  type UseScrollOptions,\n} from 'motion/react';\nimport { useRef, type RefObject } from 'react';\nimport { motionElementOf } from './motion-elements';\n\nexport type ScrollTextMode = 'word' | 'character' | 'highlight';\n\nexport type ScrollTextTag =\n  'p' | 'h1' | 'h2' | 'h3' | 'h4' | 'blockquote' | 'div' | 'span';\n\ntype ScrollOffset = UseScrollOptions['offset'];\n\ntype Range = [number, number];\n\ntype Word = {\n  text: string;\n  trailingSpace: string;\n  characters: string[];\n  firstCharacter: number;\n};\n\nconst DEFAULT_OFFSET: ScrollOffset = ['start 0.85', 'end 0.5'];\n\nconst DIMMED_OPACITY = 0.18;\n\nconst SPACE_AFTER_WORD = /^(\\S+)(\\s*)$/;\n\nfunction graphemesOf(text: string) {\n  const canSegment = typeof Intl.Segmenter === 'function';\n  if (!canSegment) {\n    return Array.from(text);\n  }\n  const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });\n  const segments = Array.from(segmenter.segment(text));\n  return segments.map((segment) => segment.segment);\n}\n\nfunction wordsOf(text: string) {\n  const tokens = text.match(/\\S+\\s*/g) ?? [];\n  const words: Word[] = [];\n  let characterCount = 0;\n\n  for (const token of tokens) {\n    const match = SPACE_AFTER_WORD.exec(token);\n    const wordText = match?.[1] ?? token;\n    const trailingSpace = match?.[2] ?? '';\n    const characters = graphemesOf(wordText);\n    words.push({\n      text: wordText,\n      trailingSpace,\n      characters,\n      firstCharacter: characterCount,\n    });\n    characterCount += characters.length;\n  }\n\n  return { words, characterCount };\n}\n\nfunction rangeOf(index: number, total: number): Range {\n  const nextIndex = index + 1;\n  const start = index / total;\n  const end = nextIndex / total;\n  return [start, end];\n}\n\nfunction revealStyleOf(isStatic: boolean | null, opacity: MotionValue<number>) {\n  if (isStatic) {\n    return undefined;\n  }\n  return { opacity };\n}\n\nfunction fillStyleOf(isStatic: boolean | null, scaleX: MotionValue<number>) {\n  if (isStatic) {\n    return undefined;\n  }\n  return { scaleX };\n}\n\nfunction RevealSpan({\n  progress,\n  range,\n  isStatic,\n  children,\n}: {\n  progress: MotionValue<number>;\n  range: Range;\n  isStatic: boolean | null;\n  children: React.ReactNode;\n}) {\n  const opacity = useTransform(progress, range, [DIMMED_OPACITY, 1]);\n\n  return (\n    <motion.span style={revealStyleOf(isStatic, opacity)}>\n      {children}\n    </motion.span>\n  );\n}\n\nfunction HighlightSpan({\n  progress,\n  range,\n  isStatic,\n  children,\n}: {\n  progress: MotionValue<number>;\n  range: Range;\n  isStatic: boolean | null;\n  children: React.ReactNode;\n}) {\n  const scaleX = useTransform(progress, range, [0, 1]);\n\n  return (\n    <span className='relative'>\n      <motion.span\n        className='bg-primary/15 absolute inset-x-0 inset-y-[0.1em] -z-10 origin-left'\n        style={fillStyleOf(isStatic, scaleX)}\n      />\n      {children}\n    </span>\n  );\n}\n\nexport type ScrollTextProps = {\n  children: string;\n  mode?: ScrollTextMode;\n  as?: ScrollTextTag;\n  offset?: ScrollOffset;\n  containerRef?: RefObject<HTMLElement | null>;\n  className?: string;\n};\n\nexport function ScrollText({\n  children,\n  mode = 'word',\n  as = 'p',\n  offset = DEFAULT_OFFSET,\n  containerRef,\n  className,\n}: ScrollTextProps) {\n  const ref = useRef<HTMLParagraphElement>(null);\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const { scrollYProgress } = useScroll({\n    target: ref,\n    container: containerRef,\n    offset,\n  });\n  const MotionComponent = motionElementOf(as) as typeof motion.p;\n  const { words, characterCount } = wordsOf(children);\n  const wordCount = words.length;\n\n  function renderWord(word: Word, wordIndex: number) {\n    const key = `${wordIndex}-${word.text}`;\n\n    if (mode === 'highlight') {\n      return (\n        <HighlightSpan\n          key={key}\n          progress={scrollYProgress}\n          range={rangeOf(wordIndex, wordCount)}\n          isStatic={shouldReduceMotion}\n        >\n          {word.text}\n          {word.trailingSpace}\n        </HighlightSpan>\n      );\n    }\n\n    if (mode === 'character') {\n      return (\n        <span key={key}>\n          {word.characters.map((character, characterIndex) => {\n            const globalIndex = word.firstCharacter + characterIndex;\n            return (\n              <RevealSpan\n                key={characterIndex}\n                progress={scrollYProgress}\n                range={rangeOf(globalIndex, characterCount)}\n                isStatic={shouldReduceMotion}\n              >\n                {character}\n              </RevealSpan>\n            );\n          })}\n          {word.trailingSpace}\n        </span>\n      );\n    }\n\n    return (\n      <RevealSpan\n        key={key}\n        progress={scrollYProgress}\n        range={rangeOf(wordIndex, wordCount)}\n        isStatic={shouldReduceMotion}\n      >\n        {word.text}\n        {word.trailingSpace}\n      </RevealSpan>\n    );\n  }\n\n  return (\n    <MotionComponent ref={ref} className={cn('isolate', className)}>\n      <span className='sr-only'>{children}</span>\n      <span aria-hidden='true'>{words.map(renderWord)}</span>\n    </MotionComponent>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/core/scroll-text.tsx"
    },
    {
      "path": "components/core/motion-elements.ts",
      "content": "import { motion } from 'motion/react';\n\nconst MOTION_ELEMENTS = {\n  a: motion.a,\n  article: motion.article,\n  aside: motion.aside,\n  blockquote: motion.blockquote,\n  button: motion.button,\n  code: motion.code,\n  div: motion.div,\n  em: motion.em,\n  figure: motion.figure,\n  footer: motion.footer,\n  h1: motion.h1,\n  h2: motion.h2,\n  h3: motion.h3,\n  h4: motion.h4,\n  h5: motion.h5,\n  h6: motion.h6,\n  header: motion.header,\n  label: motion.label,\n  li: motion.li,\n  main: motion.main,\n  nav: motion.nav,\n  ol: motion.ol,\n  p: motion.p,\n  pre: motion.pre,\n  section: motion.section,\n  small: motion.small,\n  span: motion.span,\n  strong: motion.strong,\n  ul: motion.ul,\n};\n\ntype MotionElementTag = keyof typeof MOTION_ELEMENTS;\n\nfunction isMotionElementTag(tag: unknown): tag is MotionElementTag {\n  const isString = typeof tag === 'string';\n  return isString && tag in MOTION_ELEMENTS;\n}\n\n/**\n * The motion version of an HTML tag. A lookup such as `motion[tag]` is invisible to the bundler,\n * which drops the tags it never sees and breaks the production build, so every tag is listed here.\n */\nexport function motionElementOf(tag: unknown) {\n  if (isMotionElementTag(tag)) {\n    return MOTION_ELEMENTS[tag];\n  }\n  return MOTION_ELEMENTS.div;\n}\n",
      "type": "registry:component",
      "target": "components/core/motion-elements.ts"
    }
  ],
  "categories": [
    "motion"
  ],
  "type": "registry:component"
}