{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"motion-odometer","type":"registry:lib","title":"Motion odometer","description":"A number on odometer wheels, as the motion NumberInput and Slider show their values: each digit turns as far as it","dependencies":["motion"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/motion-reduced.json","https://dashral.com/r/motion-tokens.json"],"files":[{"path":"registry/dashral/lib/motion-odometer.tsx","type":"registry:file","target":"components/dashral/lib/motion-odometer.tsx","content":"\"use client\"\n\n// A number on odometer wheels, as the motion NumberInput and Slider show their values: each digit turns as far as it\n// really travelled, softly blurred while it turns, and the number's width glides as digits come and go.\n\nimport * as React from \"react\"\nimport { AnimatePresence, animate, motion, useMotionValue, useTransform, type MotionValue } from \"motion/react\"\n\nimport { cn } from \"./utils\"\n\nimport { useReducedMotion } from \"./motion-reduced\"\nimport { duration, ease, spring } from \"./motion-tokens\"\n\ntype Column = { key: string; count: number } | { key: string; text: string }\n\n/**\n * The number as columns keyed by place (ones, tens, tenths…), so the ones stay the ones as it grows. A digit's count\n * is the whole number of its place units (1,284 has 128 tens), so a column knows how far it really turned.\n */\nfunction columnsOf(value: number, format: Intl.NumberFormat): Column[] {\n  const parts = format.formatToParts(value)\n  const fractionDigits = parts.filter((p) => p.type === \"fraction\").reduce((n, p) => n + p.value.length, 0)\n  // From the digits as shown, so a percent (0.45 shown as 45%) or a rounded value counts what you see.\n  const scaled = Number(parts.filter((p) => p.type === \"integer\" || p.type === \"fraction\").map((p) => p.value).join(\"\")) || 0\n  let place = parts.filter((p) => p.type === \"integer\").reduce((n, p) => n + p.value.length, 0)\n  let fraction = 0\n  return parts.flatMap((part, index): Column[] => {\n    if (part.type === \"integer\")\n      return [...part.value].map(() => {\n        place--\n        return { key: `i${place}`, count: Math.floor(scaled / 10 ** (place + fractionDigits)) }\n      })\n    if (part.type === \"fraction\")\n      return [...part.value].map(() => {\n        const at = fractionDigits - 1 - fraction++\n        return { key: `f${fraction}`, count: Math.floor(scaled / 10 ** at) }\n      })\n    return [{ key: part.type === \"group\" ? `g${place}` : part.type === \"decimal\" ? \"d\" : `${part.type}${index}`, text: part.value }]\n  })\n}\n\n/**\n * The columns, with the number's width gliding to fit as columns come and go (9 to 10 opens a tens column): a new\n * column rolls in the way the number moved, and one that's no longer needed rolls out.\n */\nexport function Odometer({ value, format, className }: { value: number; format: Intl.NumberFormat; className?: string }) {\n  const reduced = useReducedMotion()\n  const [shown, setShown] = React.useState(value)\n  const [rise, setRise] = React.useState(1)\n  if (shown !== value) {\n    setShown(value)\n    setRise(value < shown ? -1 : 1)\n  }\n\n  // The visible width follows the columns' on a glide; the first measure is at once.\n  const inner = React.useRef<HTMLSpanElement>(null)\n  const width = useMotionValue<number | \"auto\">(\"auto\")\n  React.useEffect(() => {\n    const node = inner.current\n    if (!node) return\n    const observer = new ResizeObserver(([entry]) => {\n      const next = entry?.borderBoxSize[0]?.inlineSize ?? node.offsetWidth\n      if (!next) return\n      if (reduced || width.get() === \"auto\") width.jump(next)\n      else animate(width, next, spring.glide)\n    })\n    observer.observe(node)\n    return () => observer.disconnect()\n  }, [reduced, width])\n\n  const slot = {\n    enter: (dir: number) => (reduced ? { opacity: 0 } : { opacity: 0, y: `${dir * 100}%`, filter: \"blur(2px)\" }),\n    shown: { opacity: 1, y: 0, filter: \"blur(0px)\" },\n    leave: (dir: number) => (reduced ? { opacity: 0 } : { opacity: 0, y: `${dir * -100}%`, filter: \"blur(2px)\" }),\n  }\n  return (\n    <motion.span className={cn(\"flex justify-center overflow-hidden\", className)} style={{ width }}>\n      <span ref={inner} className=\"relative flex w-max shrink-0\">\n        {/* popLayout lifts a leaving column out of the row at once, so the new width is measured straight away. */}\n        <AnimatePresence mode=\"popLayout\" initial={false} custom={rise}>\n          {columnsOf(value, format).map((column) => (\n            <motion.span\n              key={column.key}\n              className=\"inline-flex\"\n              custom={rise}\n              variants={slot}\n              initial=\"enter\"\n              animate=\"shown\"\n              exit=\"leave\"\n              transition={reduced ? { duration: 0 } : { duration: duration.standard, ease: ease.enter }}\n            >\n              {\"count\" in column ? <Wheel count={column.count} reduced={reduced} /> : column.text}\n            </motion.span>\n          ))}\n        </AnimatePresence>\n      </span>\n    </motion.span>\n  )\n}\n\n/**\n * One column: the ten digits on a wheel. It turns as many places as its count moved (capped near two turns, so a\n * huge jump still lands quickly), takes longer the farther it goes, and blurs with its speed, sharp again at rest.\n */\nfunction Wheel({ count, reduced }: { count: number; reduced: boolean }) {\n  const position = useMotionValue(count)\n  // Blur from the wheel's speed as it turns, and none once it rests.\n  const filter = useMotionValue(\"none\")\n  const turned = React.useRef({ count, target: count })\n  React.useLayoutEffect(() => {\n    const wheel = turned.current\n    const delta = count - wheel.count\n    if (!delta) return\n    wheel.count = count\n    // Past two turns it keeps only the last few places (same digit to land on), so it never spins for long.\n    const travel = Math.abs(delta) > 20 ? Math.sign(delta) * (20 + (Math.abs(delta) % 10)) : delta\n    wheel.target += travel\n    if (reduced) {\n      position.jump(wheel.target)\n      return\n    }\n    const controls = animate(position, wheel.target, {\n      type: \"spring\",\n      visualDuration: Math.min(0.32 + Math.abs(travel) * 0.025, 0.85),\n      bounce: 0.12,\n      onUpdate: () => {\n        // A soft blur on any turn (a single step too), growing with speed for a long spin.\n        const speed = Math.abs(position.getVelocity())\n        filter.set(speed < 0.4 ? \"none\" : `blur(${Math.min(0.6 + speed * 0.04, 2).toFixed(2)}px)`)\n      },\n      onComplete: () => filter.set(\"none\"),\n    })\n    return () => {\n      controls.stop()\n      filter.set(\"none\")\n    }\n  }, [count, filter, position, reduced])\n  return (\n    <motion.span className=\"relative inline-grid overflow-hidden\" style={{ filter }}>\n      <span className=\"invisible\">0</span>\n      {[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((digit) => (\n        <Glyph key={digit} digit={digit} position={position} />\n      ))}\n    </motion.span>\n  )\n}\n\n/** Where a digit sits on its wheel: on the line when the wheel is at it, a line below for the next one up. */\nfunction Glyph({ digit, position }: { digit: number; position: MotionValue<number> }) {\n  const away = (at: number) => {\n    let d = (digit - at) % 10\n    if (d > 5) d -= 10\n    if (d < -5) d += 10\n    return d\n  }\n  const y = useTransform(position, (at) => `${away(at) * 100}%`)\n  const opacity = useTransform(position, (at) => Math.max(0, 1 - Math.abs(away(at)) ** 1.4))\n  const visibility = useTransform(position, (at) => (Math.abs(away(at)) >= 1 ? \"hidden\" : \"visible\"))\n  return (\n    <motion.span className=\"absolute inset-0 text-center\" style={{ y, opacity, visibility }}>\n      {digit}\n    </motion.span>\n  )\n}\n"}]}