{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "slot-text",
  "type": "registry:ui",
  "title": "Slot text",
  "description": "Text and numbers that spin into their new value on staggered slot machine reels.",
  "categories": [
    "special"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/slot-text/slot-text.tsx",
      "content": "\"use client\";\n\nimport { useLayoutEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { CSSProperties } from \"react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useMotionValue,\n  usePresence,\n  useReducedMotion,\n  useTransform,\n  useVelocity,\n} from \"motion/react\";\nimport { cn } from \"../lib/utils\";\n\n/**\n * Text and numbers that spin into their new value like slot machine reels. Every character is a reel; digits count through\n * the wheel in the direction the number moved, letters shuffle through the alphabet, and reels stop one after another from\n * left to right with a soft, overshoot-free landing. Use it for prices, stats, and launch moments where a change deserves a beat.\n */\nexport interface SlotTextProps {\n  /** The value to show. Numbers pass through format; strings render as they are. */\n  value: string | number;\n  /** Formats a number value. Defaults to en-US grouping, so 12480 reads 12,480. */\n  format?: (value: number) => string;\n  /** Seconds the first reel spins. Later reels add stagger. */\n  duration?: number;\n  /** Seconds between reels stopping, left to right. */\n  stagger?: number;\n  /** Extra full turns a digit makes before it lands. 0 rolls straight to the new digit. */\n  spins?: number;\n  /** Which end reels are matched from when the length changes. Numbers default to end, so 999 to 1,000 grows on the left. */\n  align?: \"start\" | \"end\";\n  /** Announce new values politely to screen readers. */\n  announce?: boolean;\n  className?: string;\n  style?: CSSProperties;\n}\n\nconst DIGITS = \"0123456789\";\nconst LOWER = \"abcdefghijklmnopqrstuvwxyz\";\nconst UPPER = LOWER.toUpperCase();\n/** Fast out, long soft landing, never past the target. */\nconst LANDING = [0.12, 0.8, 0.16, 1] as const;\nconst subscribe = () => () => {};\n/** Cell pitch in em. Keep in sync with the cell height (h-[1.6em]) below. */\nconst CELL = 1.6;\n\nfunction useReducedFlag() {\n  const hydrated = useSyncExternalStore(\n    subscribe,\n    () => true,\n    () => false,\n  );\n  return !!useReducedMotion() && hydrated;\n}\n\nconst classOf = (char: string) =>\n  DIGITS.includes(char) ? DIGITS : LOWER.includes(char) ? LOWER : UPPER.includes(char) ? UPPER : null;\n\n/** Deterministic shuffle so the same change always spins the same letters. */\nfunction seeded(seed: number) {\n  let state = seed >>> 0 || 1;\n  return () => {\n    state = (state * 1664525 + 1013904223) >>> 0;\n    return state / 4294967296;\n  };\n}\n\n/** The reel's cells, from what is visible now to the target. */\nfunction buildStrip(from: string, to: string, spins: number, rising: boolean, seed: number) {\n  if (from === to) return [to];\n  const set = classOf(to);\n  if (!set) return [from, to];\n  if (set === DIGITS && classOf(from) === DIGITS) {\n    const a = Number(from),\n      b = Number(to);\n    const steps = spins * 10 + (rising ? (b - a + 10) % 10 : (a - b + 10) % 10);\n    return Array.from({ length: steps + 1 }, (_, i) => String((a + (rising ? i : -i) + 100) % 10));\n  }\n  const random = seeded(seed);\n  const fillers = Array.from({ length: Math.max(2, spins * 5 + 2) }, () => set[Math.floor(random() * set.length)]);\n  return [from, ...fillers, to];\n}\n\ninterface ReelProps {\n  char: string;\n  order: number;\n  entering: boolean;\n  rising: boolean;\n  duration: number;\n  stagger: number;\n  spins: number;\n  reduced: boolean;\n}\n\nfunction Reel({ char, order, entering, rising, duration, stagger, spins, reduced }: ReelProps) {\n  const pos = useMotionValue(0);\n  const width = useMotionValue<number | \"auto\">(\"auto\");\n  const sizer = useRef<HTMLSpanElement>(null);\n  const [isPresent, safeToRemove] = usePresence();\n  const [reel, setReel] = useState(() => ({\n    char,\n    strip: entering && !reduced ? buildStrip(\"\", char, spins, rising, char.charCodeAt(0) + order) : [char],\n    from: 0,\n    grow: entering,\n    time: duration + order * stagger,\n  }));\n\n  // A new target restarts the reel from the cell that is on screen right now, keeping its sub-cell offset, so nothing jumps.\n  if (reel.char !== char) {\n    const current = pos.get();\n    const index = Math.max(0, Math.min(reel.strip.length - 1, Math.round(current)));\n    const visible = reel.strip[index]!;\n    setReel({\n      char,\n      strip: reduced\n        ? [char]\n        : buildStrip(\n            visible,\n            char,\n            spins,\n            rising,\n            char.charCodeAt(0) * 31 + visible.charCodeAt(0) * 7 + order + reel.strip.length,\n          ),\n      from: reduced ? 0 : current - index,\n      grow: false,\n      time: duration + order * stagger,\n    });\n  }\n\n  const blur = useTransform(useVelocity(pos), (velocity) => {\n    const amount = Math.min(Math.abs(velocity) * 0.05, 2.4);\n    return amount < 0.15 ? \"none\" : `blur(${amount.toFixed(2)}px)`;\n  });\n  // Cells are taller than the line, so the neighbours of a landed glyph sit fully outside the window.\n  const y = useTransform(pos, (value) => `translate3d(0, ${(-value * CELL).toFixed(4)}em, 0)`);\n\n  useLayoutEffect(() => {\n    const measured = sizer.current?.getBoundingClientRect().width ?? 0;\n    const last = reel.strip.length - 1;\n    if (reduced) {\n      pos.jump(last);\n      width.jump(measured);\n      return;\n    }\n    pos.jump(reel.from);\n    const spin = animate(pos, last, { duration: last === 0 ? 0.2 : reel.time, ease: [...LANDING] });\n    if (reel.grow) width.jump(0);\n    if (width.get() === \"auto\") {\n      width.jump(measured);\n      return () => spin.stop();\n    }\n    const size = animate(width, measured, { type: \"spring\", visualDuration: Math.min(reel.time, 0.6), bounce: 0 });\n    return () => {\n      spin.stop();\n      size.stop();\n    };\n  }, [reel, pos, width, reduced]);\n\n  useLayoutEffect(() => {\n    if (isPresent) return;\n    if (reduced) {\n      safeToRemove();\n      return;\n    }\n    const exit = animate(width, 0, { type: \"spring\", visualDuration: 0.35, bounce: 0, onComplete: safeToRemove });\n    return () => exit.stop();\n  }, [isPresent, safeToRemove, width, reduced]);\n\n  // Each slot holds one character. Its width follows the character on a spring, so the word never resizes in one frame.\n  return (\n    <motion.span\n      className=\"relative inline-block h-[1.15em] flex-none overflow-visible\"\n      style={{ width }}\n      data-exiting={isPresent ? undefined : \"\"}\n    >\n      <span ref={sizer} className=\"pointer-events-none invisible inline-block whitespace-pre\">\n        {char === \" \" ? \"\\u00a0\" : char}\n      </span>\n      {/* The window reaches a little past the line so glyphs fade at the edges instead of being cut. */}\n      <span className=\"absolute -inset-x-[.08em] -inset-y-[.22em] overflow-hidden [mask-image:linear-gradient(to_bottom,transparent,#000_.24em,#000_calc(100%_-_.24em),transparent)]\">\n        <motion.span\n          className=\"absolute top-[calc(.22em_-_.225em)] right-[.08em] left-[.08em] flex flex-col items-center will-change-transform\"\n          style={{ transform: y, filter: blur }}\n        >\n          {reel.strip.map((cell, i) => (\n            <span key={i} className=\"box-border block h-[1.6em] pt-[.225em] leading-[1.15] whitespace-pre\">\n              {cell === \" \" ? \"\\u00a0\" : cell}\n            </span>\n          ))}\n        </motion.span>\n      </span>\n    </motion.span>\n  );\n}\n\nexport function SlotText({\n  value,\n  format,\n  duration = 0.9,\n  stagger = 0.07,\n  spins = 1,\n  align,\n  announce = false,\n  className,\n  style,\n}: SlotTextProps) {\n  const reduced = useReducedFlag();\n  const text = typeof value === \"number\" ? (format ? format(value) : value.toLocaleString(\"en-US\")) : value;\n  const fromEnd = (align ?? (typeof value === \"number\" ? \"end\" : \"start\")) === \"end\";\n  const chars = Array.from(text);\n  // A leading run of symbols (a currency sign, a plus) is keyed from the start and never spins into a digit; the rest is\n  // keyed from the aligned end, so separators and suffixes stay on their own reels when the number grows.\n  const lead = fromEnd ? chars.findIndex((char) => /[\\p{L}\\p{N}]/u.test(char)) : 0;\n  const prefix = lead < 0 ? chars.length : lead;\n  const keyOf = (i: number) => (i < prefix ? `p${i}` : fromEnd ? `e${chars.length - 1 - i}` : `s${i}`);\n  const [initialKeys] = useState(() => new Set(chars.map((_, i) => keyOf(i))));\n  const [trend, setTrend] = useState({ value, rising: true });\n  if (trend.value !== value) {\n    setTrend({\n      value,\n      rising: typeof value === \"number\" && typeof trend.value === \"number\" ? value >= trend.value : true,\n    });\n  }\n\n  return (\n    <span\n      className={cn(\"relative inline-block align-bottom leading-[1.15] whitespace-nowrap tabular-nums\", className)}\n      style={style}\n    >\n      <span className=\"sr-only\" aria-live={announce ? \"polite\" : undefined}>\n        {text}\n      </span>\n      <span className=\"inline-flex h-[1.15em] items-start\" aria-hidden=\"true\">\n        <AnimatePresence initial={false}>\n          {chars.map((char, i) => (\n            <Reel\n              key={keyOf(i)}\n              char={char}\n              order={i}\n              entering={!initialKeys.has(keyOf(i))}\n              rising={trend.rising}\n              duration={duration}\n              stagger={stagger}\n              spins={spins}\n              reduced={reduced}\n            />\n          ))}\n        </AnimatePresence>\n      </span>\n    </span>\n  );\n}\n\nexport default SlotText;\n",
      "type": "registry:component",
      "target": "@components/iris/slot-text/slot-text.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/slot-text",
  "meta": {
    "kind": "component",
    "group": "Text & motion",
    "export": "SlotText",
    "tags": [
      "special",
      "text",
      "numbers",
      "new"
    ],
    "docs": "https://irisui.dev/components/slot-text"
  }
}
