{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "eased-number-roll",
  "title": "Number Roll primitive",
  "description": "The rolling-digits engine every Eased number uses: Intl-formatted parts, each digit a ten-face cylinder on a spring that rolls in the direction of change, with locale-aware separators and numerals.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/_primitives/number-roll/number-roll.tsx",
      "content": "\"use client\";\n\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useMotionValue,\n  useTransform,\n  type MotionValue,\n  type Transition,\n} from \"motion/react\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { exitFast, fade, instant, snappy, useSafeReducedMotion } from \"../../lib/motion\";\n\nexport type RollDirection = \"up\" | \"down\";\n\n/** One piece of a formatted number: a digit that rolls, or a symbol that swaps. */\nexport type RollPart =\n  | { kind: \"digit\"; key: string; digit: number; text: string }\n  | { kind: \"symbol\"; key: string; text: string };\n\nexport type NumberRollFormat = {\n  /** Locale(s) for `Intl.NumberFormat`. Defaults to the runtime locale. */\n  locale?: Intl.LocalesArgument;\n  /** @deprecated Use `locale`. Kept for one release. */\n  locales?: Intl.LocalesArgument;\n  /** `Intl.NumberFormat` options: currency, percent, compact, units, fixed decimals … */\n  format?: Intl.NumberFormatOptions;\n  /**\n   * Format the number yourself. Overrides `locale`/`format`; digits in the returned string\n   * roll and everything else swaps. Places are counted from the right of the string.\n   */\n  formatter?: (value: number) => string;\n};\n\nexport type NumberRollProps = NumberRollFormat & {\n  value: number;\n  /**\n   * Which way changed digits travel. `auto` (default) rolls up as the number's size grows and\n   * down as it shrinks, like an odometer; `up`/`down` force one direction.\n   */\n  direction?: \"auto\" | RollDirection;\n  /** False shows changes at once, e.g. while a drag drives the value. Default true. */\n  animated?: boolean;\n  /** The spring each digit rides. Default `snappy`. */\n  transition?: Transition;\n  /**\n   * Hide the number from assistive tech when something else already announces it\n   * (a progressbar's aria-valuenow, a label that includes it). Default false.\n   */\n  \"aria-hidden\"?: boolean;\n  className?: string;\n};\n\nconst FACES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];\n\n/** Splits a number into keyed parts. Integer places are keyed from the decimal point, so the ones column stays put as the number grows. */\nexport function toRollParts(value: number, { locale, locales, format, formatter }: NumberRollFormat = {}): RollPart[] {\n  if (formatter) return stringParts(formatter(value));\n  const raw = new Intl.NumberFormat(locale ?? locales, format).formatToParts(value);\n  const intDigits = raw.filter((p) => p.type === \"integer\").reduce((n, p) => n + p.value.length, 0);\n  const parts: RollPart[] = [];\n  const seen: Record<string, number> = {};\n  let place = intDigits; // integer places still to the right of the next digit, plus one\n  let fraction = 0;\n  for (const p of raw) {\n    if (p.type === \"integer\") {\n      for (const ch of p.value) {\n        place--;\n        const d = digitOf(ch);\n        parts.push(d === null ? { kind: \"symbol\", key: `i${place}:${ch}`, text: ch } : { kind: \"digit\", key: `i${place}`, digit: d, text: ch });\n      }\n    } else if (p.type === \"fraction\") {\n      for (const ch of p.value) {\n        const d = digitOf(ch);\n        parts.push(d === null ? { kind: \"symbol\", key: `f${fraction}:${ch}`, text: ch } : { kind: \"digit\", key: `f${fraction}`, digit: d, text: ch });\n        fraction++;\n      }\n    } else if (p.type === \"group\") {\n      parts.push({ kind: \"symbol\", key: `g${place}`, text: p.value });\n    } else if (p.type === \"decimal\") {\n      parts.push({ kind: \"symbol\", key: \"dec\", text: p.value });\n    } else {\n      // Currency, signs, percent, compact suffixes, units, literals: keyed by type and content,\n      // so \"K\" → \"M\" swaps while \"$\" stays and slides.\n      const n = (seen[p.type] = (seen[p.type] ?? -1) + 1);\n      parts.push({ kind: \"symbol\", key: `${p.type}${n}:${p.value}`, text: p.value });\n    }\n  }\n  return parts;\n}\n\nfunction stringParts(text: string): RollPart[] {\n  const chars = [...text];\n  return chars.map((ch, i) => {\n    const place = chars.length - 1 - i;\n    const d = digitOf(ch);\n    return d === null ? { kind: \"symbol\", key: `s${place}:${ch}`, text: ch } : { kind: \"digit\", key: `c${place}`, digit: d, text: ch };\n  });\n}\n\n/** Reads ASCII and other Unicode decimal digits (Arabic-Indic, Devanagari …), so localized numerals roll too. */\nfunction digitOf(ch: string): number | null {\n  if (ch >= \"0\" && ch <= \"9\") return ch.charCodeAt(0) - 48;\n  if (!/\\p{Nd}/u.test(ch)) return null;\n  // Unicode lays every decimal script out as contiguous runs of ten starting at zero.\n  let code = ch.codePointAt(0)!;\n  let n = 0;\n  while (n < 60 && /\\p{Nd}/u.test(String.fromCodePoint(code - 1))) {\n    code--;\n    n++;\n  }\n  return n % 10;\n}\n\n/**\n * Tabular digits that roll in the direction of change, with locale-aware separators.\n * Each digit is a ten-face cylinder on a spring, so a 9 → 0 carry rolls forward instead of\n * rewinding, and a value that changes mid-roll redirects from where the digit is.\n * New places fade in from the side they roll from; the rest slide over to make room.\n */\nexport function NumberRoll({\n  value,\n  locale: localeProp,\n  locales,\n  format,\n  formatter,\n  direction = \"auto\",\n  animated = true,\n  transition = snappy,\n  \"aria-hidden\": ariaHidden = false,\n  className = \"\",\n}: NumberRollProps) {\n  const reduced = useSafeReducedMotion();\n  const roll = animated && !reduced;\n  const locale = localeProp ?? locales;\n\n  // Direction comes from the change between renders (adjusting state while rendering, not an effect).\n  const [last, setLast] = useState({ value, dir: 1 as 1 | -1 });\n  if (value !== last.value) {\n    const grew = Math.abs(value) >= Math.abs(last.value);\n    setLast({ value, dir: direction === \"up\" ? 1 : direction === \"down\" ? -1 : grew ? 1 : -1 });\n  }\n  const dir = direction === \"auto\" ? last.dir : direction === \"up\" ? 1 : -1;\n\n  // `format` is usually an inline object; key the memo on its contents.\n  const formatKey = JSON.stringify(format ?? null);\n  const options = useMemo(() => JSON.parse(formatKey) as Intl.NumberFormatOptions | null, [formatKey]);\n  const parts = useMemo(\n    () => toRollParts(value, { locale, format: options ?? undefined, formatter }),\n    [value, locale, options, formatter],\n  );\n  const label = parts.map((p) => p.text).join(\"\");\n\n  const enterY = `${dir * 50}%`;\n\n  return (\n    <span className={`relative inline-flex whitespace-nowrap tabular-nums ${className}`}>\n      {!ariaHidden && <span className=\"sr-only\">{label}</span>}\n      <span aria-hidden className=\"inline-flex select-none\">\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {parts.map((part) => (\n            <motion.span\n              key={part.key}\n              layout={roll ? \"position\" : false}\n              className=\"inline-block whitespace-pre\"\n              initial={roll ? { opacity: 0, y: enterY, filter: \"blur(2px)\" } : { opacity: 0 }}\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={\n                roll\n                  ? { opacity: 0, y: `${-dir * 50}%`, filter: \"blur(2px)\", transition: exitFast }\n                  : { opacity: 0, transition: instant }\n              }\n              transition={roll ? { ...transition, opacity: fade, filter: fade } : instant}\n            >\n              {part.kind === \"digit\" ? (\n                <Digit digit={part.digit} zero={part.text.codePointAt(0)! - part.digit} dir={dir} roll={roll} transition={transition} />\n              ) : (\n                part.text\n              )}\n            </motion.span>\n          ))}\n        </AnimatePresence>\n      </span>\n    </span>\n  );\n}\n\n/** Keeps a value in [-5, 5): a face's distance from the centre of the cylinder, in faces. */\nconst around = (x: number) => ((((x + 5) % 10) + 10) % 10) - 5;\n\ntype DigitProps = { digit: number; /** Code point of this script's zero, so faces draw in the locale's numerals. */ zero: number; dir: 1 | -1; roll: boolean; transition: Transition };\n\nfunction Digit({ digit, zero, dir, roll, transition }: DigitProps) {\n  // `pos` is the cylinder's rotation in faces; it only ever moves by whole steps in `dir`.\n  const pos = useMotionValue(digit);\n  const shown = useRef(digit);\n  const target = useRef(digit);\n\n  useEffect(() => {\n    const from = shown.current;\n    if (digit === from) return;\n    shown.current = digit;\n    const steps = dir > 0 ? (digit - from + 10) % 10 : -((from - digit + 10) % 10);\n    target.current += steps;\n    if (roll) animate(pos, target.current, transition);\n    else pos.jump(target.current);\n  }, [digit, dir, roll, transition, pos]);\n\n  return (\n    // The cell is padded 0.15em top and bottom (and pulled back by the same margin) so the\n    // feathered mask never touches the glyph of a tight display line height.\n    <span className=\"relative inline-block overflow-clip [margin-block:-0.15em] [padding-block:0.15em] [mask-image:linear-gradient(to_bottom,transparent,#000_20%,#000_80%,transparent)]\">\n      <span className=\"invisible\">{String.fromCodePoint(zero)}</span>\n      {FACES.map((face) => (\n        <Face key={face} face={face} glyph={String.fromCodePoint(zero + face)} pos={pos} />\n      ))}\n    </span>\n  );\n}\n\nfunction Face({ face, glyph, pos }: { face: number; glyph: string; pos: MotionValue<number> }) {\n  const y = useTransform(pos, (p) => `${around(face - p) * 100}%`);\n  return (\n    <motion.span style={{ y }} className=\"absolute inset-0 text-center [padding-block:0.15em]\">\n      {glyph}\n    </motion.span>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/_primitives/number-roll/number-roll.tsx"
    }
  ],
  "type": "registry:ui"
}