{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "number-roll",
  "type": "registry:ui",
  "title": "Number Roll",
  "description": "Animated number that rolls its digits odometer style when the value changes.",
  "dependencies": [],
  "registryDependencies": [],
  "files": [
    {
      "type": "registry:ui",
      "path": "components/ui/number-roll.tsx",
      "sourcePath": "packages/ui/src/components/react/ui/radix/number-roll.tsx",
      "content": "\"use client\";\n\nimport {\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n  type ComponentProps,\n  type CSSProperties,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nconst DEFAULT_DURATION = 500;\nconst DIGIT_CELLS = Array.from({ length: 10 }, (_, i) => i);\n\nlet supportsRoll: boolean | undefined;\nconst canAnimate = () => {\n  if (supportsRoll === undefined) {\n    supportsRoll =\n      typeof CSS !== \"undefined\" &&\n      typeof CSS.registerProperty === \"function\" &&\n      CSS.supports(\n        \"transform\",\n        \"translateY(clamp(-1lh, calc((mod(7.5, 10) - 5) * 1lh), 1lh))\",\n      );\n    if (supportsRoll) {\n      try {\n        CSS.registerProperty({\n          name: \"--aui-number-roll-pos\",\n          syntax: \"<number>\",\n          inherits: true,\n          initialValue: \"0\",\n        });\n      } catch {\n        /* Already registered by another copy of this component. */\n      }\n    }\n  }\n  return supportsRoll;\n};\n\n/* Cached because Intl.NumberFormat construction is expensive and inline format/locales props change identity on every parent render. */\nconst formatterCache = new Map<string, Intl.NumberFormat>();\nconst getFormatter = (\n  locales: Intl.LocalesArgument,\n  format: Intl.NumberFormatOptions | undefined,\n) => {\n  const key = `${String(locales)}\\u0000${JSON.stringify(format)}`;\n  let formatter = formatterCache.get(key);\n  if (!formatter) {\n    formatter = new Intl.NumberFormat(locales, format);\n    formatterCache.set(key, formatter);\n  }\n  return formatter;\n};\n\ntype DigitPart = { type: \"digit\"; key: string; digit: number };\ntype SymbolPart = { type: \"symbol\"; key: string; value: string };\ntype Part = DigitPart | SymbolPart;\ntype RenderedPart = Part & { exiting?: boolean; entered?: boolean };\n\nconst toParts = (\n  value: number,\n  formatter: Intl.NumberFormat,\n  prefix: string | undefined,\n  suffix: string | undefined,\n): Part[] => {\n  type Atom =\n    | { kind: \"integer\"; digit: number }\n    | { kind: \"group\"; value: string }\n    | { kind: \"fraction\"; digit: number }\n    | { kind: \"symbol\"; type: string; value: string };\n\n  const atoms: Atom[] = [];\n  if (prefix) atoms.push({ kind: \"symbol\", type: \"prefix\", value: prefix });\n  for (const part of formatter.formatToParts(value)) {\n    if (part.type === \"integer\" || part.type === \"fraction\") {\n      for (const char of part.value) {\n        const digit = char.charCodeAt(0) - 48;\n        if (digit >= 0 && digit <= 9) {\n          atoms.push({ kind: part.type, digit });\n        } else {\n          atoms.push({ kind: \"symbol\", type: part.type, value: char });\n        }\n      }\n    } else if (part.type === \"group\") {\n      atoms.push({ kind: \"group\", value: part.value });\n    } else {\n      const type =\n        part.type === \"minusSign\" || part.type === \"plusSign\"\n          ? \"sign\"\n          : part.type;\n      atoms.push({ kind: \"symbol\", type, value: part.value });\n    }\n  }\n  if (suffix) atoms.push({ kind: \"symbol\", type: \"suffix\", value: suffix });\n\n  const counts = new Map<string, number>();\n  const nextKey = (type: string) => {\n    const count = counts.get(type) ?? 0;\n    counts.set(type, count + 1);\n    return `${type}:${count}`;\n  };\n\n  /* Integer digits and group separators are keyed right to left so the ones digit is always int:0. When the digit count changes (999 -> 1,000), the surviving places keep their identity and only the new leading parts enter, instead of every column being re-assigned a new meaning. */\n  const parts: Part[] = new Array(atoms.length);\n  for (let i = atoms.length - 1; i >= 0; i--) {\n    const atom = atoms[i]!;\n    if (atom.kind === \"integer\") {\n      parts[i] = { type: \"digit\", key: nextKey(\"int\"), digit: atom.digit };\n    } else if (atom.kind === \"group\") {\n      parts[i] = { type: \"symbol\", key: nextKey(\"group\"), value: atom.value };\n    }\n  }\n  for (let i = 0; i < atoms.length; i++) {\n    const atom = atoms[i]!;\n    if (atom.kind === \"fraction\") {\n      parts[i] = { type: \"digit\", key: nextKey(\"fraction\"), digit: atom.digit };\n    } else if (atom.kind === \"symbol\") {\n      parts[i] = {\n        type: \"symbol\",\n        key: `${nextKey(atom.type)}:${atom.value}`,\n        value: atom.value,\n      };\n    }\n  }\n  return parts;\n};\n\nconst merge = (prev: RenderedPart[], next: Part[]): RenderedPart[] => {\n  const nextKeys = new Set(next.map((part) => part.key));\n  const prevKeys = new Set(prev.map((part) => part.key));\n  const out: RenderedPart[] = [];\n  let i = 0;\n  const emitExited = (until: string | undefined) => {\n    while (i < prev.length && prev[i]!.key !== until) {\n      const old = prev[i++]!;\n      if (!nextKeys.has(old.key)) {\n        out.push(old.exiting ? old : { ...old, exiting: true });\n      }\n    }\n  };\n  for (const part of next) {\n    if (prevKeys.has(part.key)) {\n      emitExited(part.key);\n      i++;\n      out.push(part);\n    } else {\n      out.push({ ...part, entered: true });\n    }\n  }\n  emitExited(undefined);\n  return out;\n};\n\nconst rollDelta = (from: number, to: number, dir: number) => {\n  const up = (((to - from) % 10) + 10) % 10;\n  if (dir > 0) return up;\n  if (dir < 0) return up - 10;\n  return up > 5 ? up - 10 : up;\n};\n\nfunction NumberRollDigit({ digit, dir }: { digit: number; dir: number }) {\n  const [state, setState] = useState({ digit, roll: digit });\n  if (state.digit !== digit) {\n    setState({ digit, roll: state.roll + rollDelta(state.digit, digit, dir) });\n  }\n\n  return (\n    <span\n      data-slot=\"number-roll-digit\"\n      className=\"relative inline-block overflow-clip [transition-property:--aui-number-roll-pos] duration-(--aui-number-roll-duration) ease-(--aui-number-roll-ease) motion-reduce:transition-none\"\n      style={{ \"--aui-number-roll-pos\": state.roll } as CSSProperties}\n    >\n      {/* Digit glyphs render through ::before so find-in-page and copy never see the strip. overflow-clip (not hidden) keeps the inline-block's baseline on the text instead of the box bottom edge. */}\n      <span\n        data-d={digit}\n        className=\"invisible before:content-[attr(data-d)]\"\n      />\n      {DIGIT_CELLS.map((cell) => (\n        <span\n          key={cell}\n          data-d={cell}\n          className=\"absolute inset-0 text-center before:content-[attr(data-d)]\"\n          style={{\n            transform: `translateY(clamp(-1lh, calc((mod(mod(${cell} - var(--aui-number-roll-pos), 10) + 5, 10) - 5) * 1lh), 1lh))`,\n          }}\n        />\n      ))}\n    </span>\n  );\n}\n\nfunction NumberRollPart({ part, dir }: { part: RenderedPart; dir: number }) {\n  return (\n    <span\n      data-slot=\"number-roll-part\"\n      className={cn(\n        \"inline-grid grid-cols-[1fr] transition-[grid-template-columns,opacity,translate] duration-(--aui-number-roll-fade) ease-out motion-reduce:transition-none\",\n        part.entered &&\n          \"starting:translate-y-(--aui-number-roll-shift) starting:grid-cols-[0fr] starting:opacity-0\",\n        part.exiting &&\n          \"pointer-events-none translate-y-[calc(var(--aui-number-roll-shift)*-1)] grid-cols-[0fr] opacity-0\",\n      )}\n      style={\n        {\n          \"--aui-number-roll-shift\":\n            dir === 0 ? \"0%\" : dir > 0 ? \"35%\" : \"-35%\",\n        } as CSSProperties\n      }\n    >\n      <span className=\"min-w-0 overflow-hidden\">\n        {part.type === \"digit\" ? (\n          <NumberRollDigit digit={part.digit} dir={dir} />\n        ) : (\n          <span data-slot=\"number-roll-symbol\" className=\"whitespace-pre\">\n            {part.value}\n          </span>\n        )}\n      </span>\n    </span>\n  );\n}\n\nexport type NumberRollProps = Omit<\n  ComponentProps<\"span\">,\n  \"children\" | \"prefix\"\n> & {\n  value: number;\n  format?: Intl.NumberFormatOptions;\n  locales?: Intl.LocalesArgument;\n  prefix?: string;\n  suffix?: string;\n  trend?: \"auto\" | \"up\" | \"down\";\n  duration?: number;\n};\n\n/**\n * Animated number that rolls digits odometer-style when the value changes. Formatting is driven by `Intl.NumberFormat`, so compact notation (\"1.1K\"), currencies, percentages, and locale-specific output animate gracefully: digits spin in place while entering and exiting characters slide and fade. Renders the plain formatted value on the server and in browsers without CSS `mod()` support; when server rendering, pass an explicit `locales` so the server and client format identically.\n *\n * ```tsx\n * <NumberRoll value={count} format={{ notation: \"compact\" }} />\n * ```\n */\nfunction NumberRoll({\n  value,\n  format,\n  locales,\n  prefix,\n  suffix,\n  trend = \"auto\",\n  duration = DEFAULT_DURATION,\n  className,\n  style,\n  ...props\n}: NumberRollProps) {\n  const [enhanced, setEnhanced] = useState(false);\n  useEffect(() => {\n    if (canAnimate()) setEnhanced(true);\n  }, []);\n\n  const formatter = getFormatter(locales, format);\n  const parts = useMemo(\n    () => toParts(value, formatter, prefix, suffix),\n    [value, formatter, prefix, suffix],\n  );\n  const formatted = `${prefix ?? \"\"}${formatter.format(value)}${suffix ?? \"\"}`;\n\n  const [display, setDisplay] = useState<{\n    value: number;\n    formatted: string;\n    rendered: RenderedPart[];\n    dir: number;\n  }>(() => ({ value, formatted, rendered: parts, dir: 0 }));\n\n  if (display.formatted !== formatted) {\n    setDisplay({\n      value,\n      formatted,\n      rendered: merge(display.rendered, parts),\n      dir:\n        trend === \"up\"\n          ? 1\n          : trend === \"down\"\n            ? -1\n            : Math.sign(value - display.value),\n    });\n  }\n\n  /* Each exiting part gets its own removal timer so a new exit batch does not extend the lifetime of parts already mid-exit. The body is idempotent, so extra runs from unrelated display changes are no-ops. */\n  const exitTimers = useRef(new Map<string, ReturnType<typeof setTimeout>>());\n  const lastDuration = useRef(duration);\n  useEffect(() => {\n    const timers = exitTimers.current;\n    if (lastDuration.current !== duration) {\n      lastDuration.current = duration;\n      for (const timer of timers.values()) clearTimeout(timer);\n      timers.clear();\n    }\n    const exiting = new Set(\n      display.rendered.filter((part) => part.exiting).map((part) => part.key),\n    );\n    for (const [key, timer] of timers) {\n      if (!exiting.has(key)) {\n        clearTimeout(timer);\n        timers.delete(key);\n      }\n    }\n    for (const key of exiting) {\n      if (timers.has(key)) continue;\n      timers.set(\n        key,\n        setTimeout(() => {\n          timers.delete(key);\n          setDisplay((current) => ({\n            ...current,\n            rendered: current.rendered.filter(\n              (part) => !(part.exiting && part.key === key),\n            ),\n          }));\n        }, duration),\n      );\n    }\n  }, [display.rendered, duration]);\n  useEffect(() => {\n    const timers = exitTimers.current;\n    return () => {\n      for (const timer of timers.values()) clearTimeout(timer);\n      timers.clear();\n    };\n  }, []);\n\n  return (\n    <span\n      data-slot=\"number-roll\"\n      className={cn(\"inline-block whitespace-nowrap tabular-nums\", className)}\n      style={\n        {\n          \"--aui-number-roll-duration\": `${duration}ms`,\n          \"--aui-number-roll-fade\":\n            \"calc(var(--aui-number-roll-duration) * 0.6)\",\n          \"--aui-number-roll-ease\": \"cubic-bezier(0.23, 1, 0.32, 1)\",\n          ...style,\n        } as CSSProperties\n      }\n      {...props}\n    >\n      <span className=\"sr-only\">{formatted}</span>\n      {enhanced ? (\n        <span aria-hidden className=\"inline-block select-none\">\n          {display.rendered.map((part) => (\n            <NumberRollPart key={part.key} part={part} dir={display.dir} />\n          ))}\n        </span>\n      ) : (\n        <span aria-hidden>{formatted}</span>\n      )}\n    </span>\n  );\n}\n\nexport { NumberRoll };\n"
    }
  ]
}