{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "rolling-number",
  "type": "registry:ui",
  "title": "Rolling number",
  "description": "A figure whose changed digits roll to the new value: each leaves half of the travel token and fades as the new one comes in from the other side, on the responsive tier, up when the value rises and down when it falls. Any Intl.NumberFormat format, tabular figures, the whole figure as text for screen readers. The first render and reduced motion just show the number.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/rolling-number.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/rolling-number 2026.10.21. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Rolling number: a figure whose changed digits roll to their new value (components.md, \"Rolling number\").\n *\n * - It is the number only, in the font, size and color it sits in, with tabular figures so no digit changes width.\n *   Pass it as a Stat's value, a Gauge caption or anywhere a count updates in place. Stat itself stays still: the\n *   roll is opt-in, by composing this.\n * - The text comes from Intl.NumberFormat (locale and options are props), so \"12,480\", \"€1,250.50\" and \"64%\" all\n *   roll. Characters are lined up from the right, so 999 to 1,000 rolls the digits that changed and the new\n *   column and the comma simply appear.\n * - Motion: only the changed characters move, each on the responsive tier: the old one leaves half of the travel\n *   token and fades, the new one comes in from the other side and fades in, inside a cell that clips them, as the\n *   Date picker's months do. Up from below when the value rises, down from above when it falls. Never further\n *   than the travel token (the digits never travel their own height), never a bounce, never the view's one\n *   expressive moment. The first render does not roll. The hooks return 0 under reduced motion, so the digits\n *   simply change.\n * - Accessibility: screen readers get the whole formatted figure as text, and the rolling cells are hidden from\n *   them. A change is not announced: put it in a live region yourself if it should be.\n */\nimport { useState } from \"react\";\nimport { AnimatePresence, motion, type Variants } from \"motion/react\";\nimport { useExit, useTransition, useTravel } from \"../../lib/litt/motion\";\n\nexport type RollingNumberProps = {\n  /** The figure. When it changes, the characters that differ roll to their new value. */\n  value: number;\n  /** Intl.NumberFormat options, such as { style: \"currency\", currency: \"EUR\" } or { style: \"percent\" }. */\n  format?: Intl.NumberFormatOptions;\n  /** The locale for the format. */\n  locale?: string;\n  /** Extra classes, such as a text size. It takes the font, size and color of where it sits. */\n  className?: string;\n};\n\n/** A number whose changed digits roll to the new value: up when it rises, down when it falls. */\nexport function RollingNumber({ value, format, locale = \"en-GB\", className = \"\" }: RollingNumberProps) {\n  const enter = useTransition(\"responsive\");\n  const exit = useExit(\"responsive\");\n  const travel = useTravel();\n  const text = new Intl.NumberFormat(locale, format).format(value);\n\n  /* Which way the last change went: 1 when it rose, -1 when it fell. Set while rendering, React's pattern for state\n     that follows a prop, so the new digits and the leaving ones both know it in the same render. */\n  const [prev, setPrev] = useState(value);\n  const [dir, setDir] = useState(1);\n  if (value !== prev) {\n    setPrev(value);\n    setDir(value > prev ? 1 : -1);\n  }\n  /* Through `custom`, so a digit that is leaving takes the newest direction too. */\n  const roll: Variants = {\n    enter: (d: number) => ({ y: (d * travel) / 2, opacity: 0 }),\n    center: { y: 0, opacity: 1, transition: enter },\n    leave: (d: number) => ({ y: (-d * travel) / 2, opacity: 0, transition: exit }),\n  };\n\n  /* Keyed by place from the right, so a new leading column never shifts the others' keys. */\n  const chars = [...text].map((ch, i, all) => ({ ch, place: all.length - 1 - i }));\n\n  return (\n    <span className={\"inline-flex tabular-nums \" + className}>\n      <span className=\"sr-only\">{text}</span>\n      <span aria-hidden=\"true\" className=\"inline-flex\">\n        {chars.map(({ ch, place }) => (\n          <span key={place} className=\"relative inline-flex overflow-hidden\">\n            <AnimatePresence initial={false} mode=\"popLayout\" custom={dir}>\n              <motion.span key={ch} custom={dir} variants={roll} initial=\"enter\" animate=\"center\" exit=\"leave\" className=\"inline-block whitespace-pre\">\n                {ch}\n              </motion.span>\n            </AnimatePresence>\n          </span>\n        ))}\n      </span>\n    </span>\n  );\n}\n",
      "target": "components/litt/rolling-number.tsx"
    }
  ],
  "docs": "Import RollingNumber from components/litt/rolling-number and pass it a number as value, with Intl.NumberFormat options as format and a locale. It takes the font, size and color of where it sits: pass it as a Stat's value to roll a tile, since Stat itself stays still. A change is not announced; say it in a live region if it matters.",
  "meta": {
    "version": "2026.10.21",
    "released": "2026-10-21",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
