{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"number-ticker","type":"registry:ui","title":"Number Ticker","description":"Odometer-style numbers – every digit rolls to its new value and counts in from zero when it scrolls into view","dependencies":[],"files":[{"path":"motion/number-ticker/number-ticker.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useEffect, useMemo, useRef, useState } from \"react\";\n\n/*\n * Number Ticker – animated numbers like an odometer, in a single self-contained file.\n * Every digit is a small vertical column that rolls to its new value: up when the number grows, down when it\n * shrinks. On first view it counts in from zero. Formatting comes from Intl.NumberFormat, so currency signs,\n * separators and percent signs stay put while only the digits move. The styles are injected below.\n */\n\nexport type NumberTickerProps = {\n  /** The number to show */\n  value: number;\n  /** Intl.NumberFormat options, e.g. { style: \"currency\", currency: \"USD\" } */\n  format?: Intl.NumberFormatOptions;\n  /** Locale for formatting, e.g. \"en-US\" – defaults to the browser's */\n  locale?: string;\n  /** Length of a roll in ms */\n  duration?: number;\n  /** Wait before the first count-in, in ms */\n  delay?: number;\n  className?: string;\n};\n\ntype Part = { key: string; digit: number } | { key: string; char: string };\n\n/** \"$48,240\" → parts keyed from the right, so the ones column keeps its identity when the number grows */\nfunction split(text: string): Part[] {\n  const chars = Array.from(text);\n  const parts: Part[] = [];\n  let d = 0;\n  let s = 0;\n  for (let i = chars.length - 1; i >= 0; i--) {\n    const c = chars[i];\n    if (c >= \"0\" && c <= \"9\") parts.unshift({ key: `d${d++}`, digit: c.charCodeAt(0) - 48 });\n    else parts.unshift({ key: `s${s++}`, char: c });\n  }\n  return parts;\n}\n\nconst mod10 = (n: number) => ((n % 10) + 10) % 10;\nconst ease = (t: number) => 1 - Math.pow(1 - t, 4);\n/** Position → transform; the stack holds 0–9 plus a trailing 0, so 9 → 0 rolls seamlessly */\nconst place = (el: HTMLElement, pos: number) => {\n  el.style.transform = `translateY(${(-mod10(pos) / 11) * 100}%)`;\n};\n\ntype Roll = { pos: number; from: number; to: number };\n\nexport function NumberTicker({ value, format, locale, duration = 900, delay = 0, className }: NumberTickerProps) {\n  const fmtKey = JSON.stringify(format ?? {});\n  const text = useMemo(\n    // NaN or ∞ (no data yet, a division by zero) shows a dash instead of the raw word\n    () => (Number.isFinite(value) ? new Intl.NumberFormat(locale, format).format(value) : \"–\"),\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [value, locale, fmtKey],\n  );\n  const parts = useMemo(() => split(text), [text]);\n\n  const root = useRef<HTMLSpanElement>(null);\n  const rolls = useRef(new Map<string, Roll>());\n  const stacks = useRef(new Map<string, HTMLElement>());\n  const prev = useRef(0);\n  const up = useRef(true);\n  const mounted = useRef(false);\n  const entering = useRef(new Set<string>());\n  const seen = useRef(new Set<string>());\n  const [started, setStarted] = useState(false);\n\n  // Columns that show up after the first render slide in (e.g. 9,999 → 10,000)\n  const keysNow = new Set(parts.filter((p) => \"digit\" in p).map((p) => p.key));\n  for (const k of keysNow) if (mounted.current && !seen.current.has(k)) entering.current.add(k);\n  for (const k of entering.current) if (!keysNow.has(k)) entering.current.delete(k);\n  seen.current = keysNow;\n\n  // Start once the number scrolls into view\n  useEffect(() => {\n    mounted.current = true;\n    const el = root.current;\n    if (!el || typeof IntersectionObserver === \"undefined\") {\n      setStarted(true);\n      return;\n    }\n    let timer = 0;\n    const io = new IntersectionObserver(\n      ([e]) => {\n        if (!e.isIntersecting) return;\n        io.disconnect();\n        timer = window.setTimeout(() => setStarted(true), delay);\n      },\n      { threshold: 0.3 },\n    );\n    io.observe(el);\n    return () => {\n      io.disconnect();\n      clearTimeout(timer);\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  // Roll every digit column to its new value\n  useEffect(() => {\n    if (!started) return;\n    // Same value again (e.g. a format change) keeps the last direction\n    if (value !== prev.current) up.current = value > prev.current;\n    prev.current = value;\n    const reduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    const keys = new Set<string>();\n\n    for (const p of parts) {\n      if (!(\"digit\" in p)) continue;\n      keys.add(p.key);\n      const r = rolls.current.get(p.key) ?? { pos: 0, from: 0, to: 0 };\n      const delta = up.current ? mod10(p.digit - r.pos) : -mod10(r.pos - p.digit);\n      r.from = r.pos;\n      r.to = r.pos + delta;\n      if (reduce) r.pos = r.from = r.to;\n      rolls.current.set(p.key, r);\n    }\n    for (const k of rolls.current.keys()) if (!keys.has(k)) rolls.current.delete(k);\n\n    const apply = () => {\n      for (const [k, r] of rolls.current) {\n        const el = stacks.current.get(k);\n        if (el) place(el, r.pos);\n      }\n    };\n    if (reduce) return apply();\n\n    let raf = 0;\n    const t0 = performance.now();\n    const tick = (now: number) => {\n      const t = Math.min(1, (now - t0) / duration);\n      const e = ease(t);\n      for (const r of rolls.current.values()) r.pos = r.from + (r.to - r.from) * e;\n      apply();\n      if (t < 1) raf = requestAnimationFrame(tick);\n    };\n    raf = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(raf);\n  }, [parts, started, value, duration]);\n\n  return (\n    <>\n      <style href=\"ui-number-ticker\" precedence=\"default\">\n        {css}\n      </style>\n      <span ref={root} className={[\"ui-nt\", className].filter(Boolean).join(\" \")} aria-live=\"polite\">\n        <span className=\"ui-nt__sr\">{text}</span>\n        <span className=\"ui-nt__track\" aria-hidden=\"true\">\n          {parts.map((p) =>\n            \"digit\" in p ? (\n              <span key={p.key} className=\"ui-nt__col\" data-enter={entering.current.has(p.key) || undefined}>\n                <span\n                  className=\"ui-nt__stack\"\n                  ref={(el) => {\n                    if (!el) return;\n                    stacks.current.set(p.key, el);\n                    place(el, rolls.current.get(p.key)?.pos ?? 0);\n                    return () => {\n                      if (stacks.current.get(p.key) === el) stacks.current.delete(p.key);\n                    };\n                  }}\n                >\n                  {[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0].map((n, i) => (\n                    <span key={i} className=\"ui-nt__cell\">\n                      {n}\n                    </span>\n                  ))}\n                </span>\n              </span>\n            ) : (\n              <span key={p.key} className=\"ui-nt__static\">\n                {p.char}\n              </span>\n            ),\n          )}\n        </span>\n      </span>\n    </>\n  );\n}\n\n/* ---------- Styles ---------- */\n\nconst css = /* css */ `\n.ui-nt {\n  --ui-nt-lh: 1.15em;\n  --ui-nt-ease: cubic-bezier(0.22, 1, 0.36, 1);\n  position: relative;\n  display: inline-flex;\n  line-height: var(--ui-nt-lh);\n  font-variant-numeric: tabular-nums;\n  white-space: nowrap;\n}\n.ui-nt__sr {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  margin: -1px;\n  padding: 0;\n  overflow: hidden;\n  clip: rect(0 0 0 0);\n  white-space: nowrap;\n  border: 0;\n}\n.ui-nt__track {\n  display: inline-flex;\n  align-items: flex-start;\n  height: var(--ui-nt-lh);\n  line-height: var(--ui-nt-lh);\n}\n.ui-nt__col {\n  display: inline-block;\n  height: var(--ui-nt-lh);\n  overflow: hidden;\n  /* Soft top and bottom edges while digits pass through */\n  -webkit-mask-image: linear-gradient(transparent, #000 14%, #000 86%, transparent);\n  mask-image: linear-gradient(transparent, #000 14%, #000 86%, transparent);\n}\n.ui-nt__col[data-enter] { animation: ui-nt-in 450ms var(--ui-nt-ease) both; }\n.ui-nt__stack {\n  display: flex;\n  flex-direction: column;\n  will-change: transform;\n}\n.ui-nt__cell,\n.ui-nt__static {\n  display: block;\n  height: var(--ui-nt-lh);\n  text-align: center;\n}\n.ui-nt__static { white-space: pre; }\n\n@keyframes ui-nt-in {\n  from { opacity: 0; transform: translateY(-35%); }\n}\n@media (prefers-reduced-motion: reduce) {\n  .ui-nt__col[data-enter] { animation: none; }\n}\n\n/* Dark mode: follows a .dark class or data-theme=\"dark\" on any ancestor, e.g. <html>.\n   The ticker inherits its color, so nothing needs to change here. */\n:where(.dark, [data-theme=\"dark\"]) .ui-nt {\n  --ui-nt-lh: 1.15em;\n}\n`;\n"}]}