{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "motion-word-roll",
  "title": "Word roll",
  "description": "A short label whose changed words roll out and the new ones rise in, in the direction of the change.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/animated-size.json",
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "components/core/word-roll.tsx",
      "content": "'use client';\nimport { cn } from '@/lib/utils';\nimport {\n  AnimatePresence,\n  LayoutGroup,\n  motion,\n  useReducedMotion,\n  type Transition,\n  type Variants,\n} from 'motion/react';\nimport { useState, type ReactNode } from 'react';\nimport { AnimatedSize } from '@/registry/ndeko/ui/animated-size';\n\nexport type WordRollDirection = 'up' | 'down';\n\n/**\n * A word is a string, or a node with a stable key: the node keeps its place while its own content\n * changes (a `MotionNumber` inside a phrase), and rolls only when its key changes.\n */\nexport type WordRollWord =\n  | string\n  | {\n      key: string;\n      text: string;\n      node: ReactNode;\n    };\n\ntype RollItem = {\n  key: string;\n  text: string;\n  node: ReactNode;\n};\n\ntype SlotChange = {\n  index: number;\n  item: RollItem | null;\n  exits: boolean;\n  enters: boolean;\n};\n\ntype SlotPlan = {\n  index: number;\n  item: RollItem | null;\n  enterDelay: number;\n  exitDelay: number;\n};\n\ntype WordMotion = {\n  direction: WordRollDirection;\n  delay: number;\n};\n\nconst ENTER_DURATION = 0.28;\n\nconst EXIT_DURATION = 0.2;\n\nconst ENTER_STAGGER = 0.07;\n\nconst EXIT_STAGGER = 0.05;\n\nconst ENTER_EASE: [number, number, number, number] = [0.22, 1, 0.36, 1];\n\nconst BLURRED = 'blur(4px)';\n\nconst SHARP = 'blur(0px)';\n\nconst RESTING = 'translateY(0%)';\n\nconst BELOW = 'translateY(110%)';\n\nconst ABOVE = 'translateY(-110%)';\n\n/** Same spring as `AnimatedSize`, so the last word and the clipping edge move together. */\nconst SLIDE: Transition = { type: 'spring', bounce: 0, duration: 0.3 };\n\nconst INSTANT: Transition = { duration: 0 };\n\nconst FADE_IN: Transition = { duration: 0.15, ease: [0.23, 1, 0.32, 1] };\n\nconst FADE_OUT: Transition = { duration: 0.1, ease: [0.23, 1, 0.32, 1] };\n\nfunction enteringOffsetOf(direction: WordRollDirection) {\n  if (direction === 'down') {\n    return ABOVE;\n  }\n  return BELOW;\n}\n\nfunction leavingOffsetOf(direction: WordRollDirection) {\n  if (direction === 'down') {\n    return BELOW;\n  }\n  return ABOVE;\n}\n\nconst ROLL_VARIANTS: Variants = {\n  hidden: (wordMotion: WordMotion) => ({\n    transform: enteringOffsetOf(wordMotion.direction),\n    filter: BLURRED,\n  }),\n  shown: (wordMotion: WordMotion) => ({\n    transform: RESTING,\n    filter: SHARP,\n    transition: {\n      duration: ENTER_DURATION,\n      ease: ENTER_EASE,\n      delay: wordMotion.delay,\n    },\n  }),\n  gone: (wordMotion: WordMotion) => ({\n    transform: leavingOffsetOf(wordMotion.direction),\n    filter: BLURRED,\n    transition: {\n      duration: EXIT_DURATION,\n      ease: 'easeIn',\n      delay: wordMotion.delay,\n    },\n  }),\n};\n\nconst FADE_VARIANTS: Variants = {\n  hidden: { opacity: 0 },\n  shown: { opacity: 1, transition: FADE_IN },\n  gone: { opacity: 0, transition: FADE_OUT },\n};\n\nfunction variantsOf(shouldReduceMotion: boolean | null) {\n  if (shouldReduceMotion) {\n    return FADE_VARIANTS;\n  }\n  return ROLL_VARIANTS;\n}\n\nfunction slideOf(shouldReduceMotion: boolean | null) {\n  if (shouldReduceMotion) {\n    return INSTANT;\n  }\n  return SLIDE;\n}\n\nfunction itemOf(word: WordRollWord): RollItem {\n  if (typeof word === 'string') {\n    return { key: `word:${word}`, text: word, node: word };\n  }\n  return { key: `node:${word.key}`, text: word.text, node: word.node };\n}\n\nfunction wordsOfText(text: string) {\n  const trimmed = text.trim();\n  if (trimmed === '') {\n    return [];\n  }\n  return trimmed.split(/\\s+/);\n}\n\nfunction itemsOf(\n  text: string | undefined,\n  words: readonly WordRollWord[] | undefined\n) {\n  if (words) {\n    return words.map(itemOf);\n  }\n  const textWords = wordsOfText(text ?? '');\n  return textWords.map(itemOf);\n}\n\nfunction haveSameKeys(first: readonly string[], second: readonly string[]) {\n  if (first.length !== second.length) {\n    return false;\n  }\n  return first.every((key, index) => key === second[index]);\n}\n\nfunction trendOf(\n  previous: number | undefined,\n  next: number | undefined,\n  current: WordRollDirection\n): WordRollDirection {\n  if (previous === undefined) {\n    return current;\n  }\n  if (next === undefined) {\n    return current;\n  }\n  if (next < previous) {\n    return 'down';\n  }\n  return 'up';\n}\n\nfunction keyOf(item: RollItem | null) {\n  if (!item) {\n    return null;\n  }\n  return item.key;\n}\n\nfunction changeAt(\n  index: number,\n  previousKeys: readonly string[],\n  items: readonly RollItem[]\n): SlotChange {\n  const previousKey = previousKeys[index] ?? null;\n  const item = items[index] ?? null;\n  const key = keyOf(item);\n  const isChanged = previousKey !== key;\n  const hadWord = previousKey !== null;\n  const hasWord = key !== null;\n  const exits = isChanged && hadWord;\n  const enters = isChanged && hasWord;\n  return { index, item, exits, enters };\n}\n\n/** How long the leaving words take as a line, so the new words rise once they are all gone. */\nfunction exitPhaseOf(exitCount: number) {\n  if (exitCount === 0) {\n    return 0;\n  }\n  const staggered = (exitCount - 1) * EXIT_STAGGER;\n  return EXIT_DURATION + staggered;\n}\n\n/**\n * Only the words that changed roll, staggered among themselves. A slot whose word leaves mounts\n * its new word when the old one is gone, so its enter delay is counted from that moment.\n */\nfunction planSlots(\n  previousKeys: readonly string[],\n  items: readonly RollItem[],\n  slotCount: number\n) {\n  const indexes = Array.from({ length: slotCount }, (_, index) => index);\n  const changes = indexes.map((index) => changeAt(index, previousKeys, items));\n  const exiting = changes.filter((change) => change.exits);\n  const exitPhase = exitPhaseOf(exiting.length);\n  const plans: SlotPlan[] = [];\n  let exitRank = 0;\n  let enterRank = 0;\n\n  for (const change of changes) {\n    let exitDelay = 0;\n    let exitEnd = 0;\n    let enterDelay = 0;\n    if (change.exits) {\n      exitDelay = exitRank * EXIT_STAGGER;\n      exitEnd = exitDelay + EXIT_DURATION;\n      exitRank += 1;\n    }\n    if (change.enters) {\n      const enterOffset = enterRank * ENTER_STAGGER;\n      const enterStart = exitPhase + enterOffset;\n      enterDelay = enterStart - exitEnd;\n      enterRank += 1;\n    }\n    plans.push({\n      index: change.index,\n      item: change.item,\n      enterDelay,\n      exitDelay,\n    });\n  }\n\n  return plans;\n}\n\nfunction leadingSpaceOf(index: number) {\n  if (index === 0) {\n    return null;\n  }\n  return ' ';\n}\n\ntype WordSlotProps = {\n  plan: SlotPlan;\n  direction: WordRollDirection;\n  variants: Variants;\n  slide: Transition;\n  initialSlotCount: number;\n};\n\nfunction WordSlot({\n  plan,\n  direction,\n  variants,\n  slide,\n  initialSlotCount,\n}: WordSlotProps) {\n  const isAddedLater = plan.index >= initialSlotCount;\n  const leadingSpace = leadingSpaceOf(plan.index);\n  const enterMotion: WordMotion = { direction, delay: plan.enterDelay };\n  const exitMotion: WordMotion = { direction, delay: plan.exitDelay };\n\n  return (\n    <motion.span\n      layout='position'\n      transition={slide}\n      className='flex overflow-y-clip'\n    >\n      <AnimatePresence mode='wait' initial={isAddedLater} custom={exitMotion}>\n        {plan.item && (\n          <motion.span\n            key={plan.item.key}\n            custom={enterMotion}\n            variants={variants}\n            initial='hidden'\n            animate='shown'\n            exit='gone'\n            className='block whitespace-pre'\n          >\n            {leadingSpace}\n            {plan.item.node}\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </motion.span>\n  );\n}\n\ntype RollFrameProps = {\n  labels: readonly string[] | undefined;\n  children: ReactNode;\n};\n\nfunction RollFrame({ labels, children }: RollFrameProps) {\n  const hasLabels = labels !== undefined && labels.length > 0;\n\n  if (hasLabels) {\n    return (\n      <span aria-hidden='true' className='inline-grid'>\n        {labels.map((label, index) => (\n          <span\n            key={`${index}-${label}`}\n            className='invisible col-start-1 row-start-1 whitespace-pre'\n          >\n            {label}\n          </span>\n        ))}\n        <span className='col-start-1 row-start-1 flex items-baseline'>\n          {children}\n        </span>\n      </span>\n    );\n  }\n\n  return (\n    <AnimatedSize\n      as='span'\n      axis='width'\n      contentClassName='flex items-baseline'\n      aria-hidden='true'\n    >\n      {children}\n    </AnimatedSize>\n  );\n}\n\ntype WordRollContent =\n  | {\n      text: string;\n      words?: never;\n    }\n  | {\n      words: readonly WordRollWord[];\n      text?: never;\n    };\n\nexport type WordRollProps = WordRollContent & {\n  labels?: readonly string[];\n  direction?: WordRollDirection;\n  value?: number;\n  accessibleText?: string;\n  className?: string;\n};\n\nexport function WordRoll({\n  text,\n  words,\n  labels,\n  direction,\n  value,\n  accessibleText,\n  className,\n}: WordRollProps) {\n  const shouldReduceMotion = useReducedMotion();\n  const items = itemsOf(text, words);\n  const keys = items.map((item) => item.key);\n  const [initialSlotCount] = useState(keys.length);\n  const [slotCount, setSlotCount] = useState(keys.length);\n  const [currentKeys, setCurrentKeys] = useState<readonly string[]>(keys);\n  const [previousKeys, setPreviousKeys] = useState<readonly string[]>(keys);\n  const [previousValue, setPreviousValue] = useState(value);\n  const [trend, setTrend] = useState<WordRollDirection>('up');\n\n  const hasNewWords = !haveSameKeys(keys, currentKeys);\n  if (hasNewWords) {\n    setPreviousKeys(currentKeys);\n    setCurrentKeys(keys);\n  }\n\n  const needsMoreSlots = keys.length > slotCount;\n  if (needsMoreSlots) {\n    setSlotCount(keys.length);\n  }\n\n  const hasNewValue = !Object.is(value, previousValue);\n  if (hasNewValue) {\n    const nextTrend = trendOf(previousValue, value, trend);\n    setPreviousValue(value);\n    setTrend(nextTrend);\n  }\n\n  const rollDirection = direction ?? trend;\n  const renderedSlotCount = Math.max(slotCount, keys.length);\n  const plans = planSlots(previousKeys, items, renderedSlotCount);\n  const variants = variantsOf(shouldReduceMotion);\n  const slide = slideOf(shouldReduceMotion);\n  const spokenWords = items.map((item) => item.text);\n  const spokenText = accessibleText ?? spokenWords.join(' ');\n\n  return (\n    <span\n      className={cn('inline-flex items-baseline whitespace-nowrap', className)}\n    >\n      <span className='sr-only'>{spokenText}</span>\n      <RollFrame labels={labels}>\n        <LayoutGroup inherit={false}>\n          {plans.map((plan) => (\n            <WordSlot\n              key={plan.index}\n              plan={plan}\n              direction={rollDirection}\n              variants={variants}\n              slide={slide}\n              initialSlotCount={initialSlotCount}\n            />\n          ))}\n        </LayoutGroup>\n      </RollFrame>\n    </span>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/core/word-roll.tsx"
    }
  ],
  "categories": [
    "motion"
  ],
  "type": "registry:component"
}