{
  "$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": [],
  "files": [
    {
      "path": "registry/components/slot-text/slot-text.tsx",
      "type": "registry:component",
      "target": "@components/xzero/slot-text/slot-text.tsx",
      "content": "\"use client\";\n\nimport { useLayoutEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { CSSProperties } from \"react\";\nimport { AnimatePresence, animate, motion, useMotionValue, usePresence, useReducedMotion, useTransform, useVelocity } from \"motion/react\";\nimport styles from \"./slot-text.module.css\";\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 = [.12, .8, .16, 1] as const;\nconst subscribe = () => () => {};\n/** Cell pitch in em. Keep in sync with .cell in the stylesheet. */\nconst CELL = 1.6;\n\nfunction useReducedFlag() {\n  const hydrated = useSyncExternalStore(subscribe, () => true, () => false);\n  return !!useReducedMotion() && hydrated;\n}\n\nconst classOf = (char: string) => 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 () => { state = (state * 1664525 + 1013904223) >>> 0; return state / 4294967296; };\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), 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 ? [char] : buildStrip(visible, char, spins, rising, char.charCodeAt(0) * 31 + visible.charCodeAt(0) * 7 + order + reel.strip.length),\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) * .05, 2.4);\n    return amount < .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 ? .2 : reel.time, ease: [...LANDING] });\n    if (reel.grow) width.jump(0);\n    if (width.get() === \"auto\") { width.jump(measured); return () => spin.stop(); }\n    const size = animate(width, measured, { type: \"spring\", visualDuration: Math.min(reel.time, .6), bounce: 0 });\n    return () => { spin.stop(); size.stop(); };\n  }, [reel, pos, width, reduced]);\n\n  useLayoutEffect(() => {\n    if (isPresent) return;\n    if (reduced) { safeToRemove(); return; }\n    const exit = animate(width, 0, { type: \"spring\", visualDuration: .35, bounce: 0, onComplete: safeToRemove });\n    return () => exit.stop();\n  }, [isPresent, safeToRemove, width, reduced]);\n\n  return <motion.span className={styles.slot} style={{ width }} data-exiting={isPresent ? undefined : \"\"}>\n    <span ref={sizer} className={styles.sizer}>{char === \" \" ? \"\\u00a0\" : char}</span>\n    <span className={styles.window}>\n      <motion.span className={styles.strip} style={{ transform: y, filter: blur }}>\n        {reel.strip.map((cell, i) => <span key={i} className={styles.cell}>{cell === \" \" ? \"\\u00a0\" : cell}</span>)}\n      </motion.span>\n    </span>\n  </motion.span>;\n}\n\nexport function SlotText({ value, format, duration = .9, stagger = .07, spins = 1, align, announce = false, className, style }: 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({ value, rising: typeof value === \"number\" && typeof trend.value === \"number\" ? value >= trend.value : true });\n  }\n\n  return <span className={[styles.root, className].filter(Boolean).join(\" \")} style={style}>\n    <span className={styles.srOnly} aria-live={announce ? \"polite\" : undefined}>{text}</span>\n    <span className={styles.reels} aria-hidden=\"true\">\n      <AnimatePresence initial={false}>\n        {chars.map((char, i) => <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      </AnimatePresence>\n    </span>\n  </span>;\n}\n\nexport default SlotText;\n"
    },
    {
      "path": "registry/components/slot-text/slot-text.module.css",
      "type": "registry:component",
      "target": "@components/xzero/slot-text/slot-text.module.css",
      "content": ".root { position: relative; display: inline-block; font-variant-numeric: tabular-nums; line-height: 1.15; white-space: nowrap; vertical-align: bottom; }\n.reels { display: inline-flex; height: 1.15em; align-items: flex-start; }\n\n/* Each slot holds one character. Its width follows the character on a spring, so the word never resizes in one frame. */\n.slot { position: relative; display: inline-block; height: 1.15em; flex: none; overflow: visible; }\n.sizer { display: inline-block; visibility: hidden; white-space: pre; pointer-events: none; }\n\n/* The window reaches a little past the line so glyphs fade at the edges instead of being cut. */\n.window {\n  position: absolute;\n  inset: -.22em -.08em;\n  overflow: hidden;\n  -webkit-mask-image: linear-gradient(to bottom, transparent, #000 .24em, #000 calc(100% - .24em), transparent);\n  mask-image: linear-gradient(to bottom, transparent, #000 .24em, #000 calc(100% - .24em), transparent);\n}\n.strip { position: absolute; top: calc(.22em - .225em); right: .08em; left: .08em; display: flex; flex-direction: column; align-items: center; will-change: transform; }\n.cell { display: block; height: 1.6em; padding-top: .225em; box-sizing: border-box; line-height: 1.15; white-space: pre; }\n\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/slot-text",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "special",
      "text",
      "numbers",
      "new"
    ],
    "docs": "https://ui.xzero.co.uk/components/slot-text",
    "markdown": "https://ui.xzero.co.uk/components/slot-text/markdown"
  }
}