{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"odometer","type":"registry:ui","title":"Odometer","description":"An odometer-style number where every digit column rolls on a spring, with − / + controls and soft faded edges.","dependencies":["motion","lucide-react"],"registryDependencies":[],"files":[{"path":"registry/ui/odometer.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion, useSpring, useTransform, type MotionValue } from \"motion/react\";\nimport { Minus, Plus } from \"lucide-react\";\n\nexport interface OdometerProps {\n  /** Starting value (non-negative integer). Changing it jumps the counter there. */\n  value?: number;\n  /** Amount added or removed by the buttons. */\n  step?: number;\n  min?: number;\n  max?: number;\n  /** Digit size in px. */\n  fontSize?: number;\n  /** Show the − / + buttons. */\n  showControls?: boolean;\n  /** Fade digits out at the top and bottom edges. */\n  fade?: boolean;\n  /** Called with the new value after a button press. */\n  onChange?: (value: number) => void;\n  className?: string;\n}\n\nexport function Odometer({\n  value = 128,\n  step = 1,\n  min = 0,\n  max = 999999,\n  fontSize = 80,\n  showControls = true,\n  fade = true,\n  onChange,\n  className,\n}: OdometerProps) {\n  const clamp = (v: number) => Math.round(Math.min(max, Math.max(min, v)));\n  const [n, setN] = useState(() => clamp(value));\n  const [prev, setPrev] = useState(value);\n  if (value !== prev) {\n    setPrev(value);\n    setN(clamp(value));\n  }\n\n  const bump = (dir: number) => {\n    const next = clamp(n + dir * step);\n    setN(next);\n    onChange?.(next);\n  };\n\n  const digits = String(Math.max(0, n)).length;\n  const places = Array.from({ length: digits }, (_, i) => digits - 1 - i);\n  const h = fontSize * 1.1;\n  const mask = fade ? \"linear-gradient(transparent, #000 28%, #000 72%, transparent)\" : undefined;\n  const btn =\n    \"grid size-11 shrink-0 place-items-center rounded-full border border-border bg-card text-foreground outline-none transition-[transform,background-color] hover:bg-muted active:scale-90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40\";\n\n  return (\n    <div className={`inline-flex items-center gap-5 ${className ?? \"\"}`}>\n      {showControls && (\n        <button type=\"button\" aria-label={`Decrease by ${step}`} className={btn} disabled={n <= min} onClick={() => bump(-1)}>\n          <Minus className=\"size-5\" aria-hidden />\n        </button>\n      )}\n      <div\n        className=\"relative flex overflow-hidden font-semibold leading-none tabular-nums text-foreground\"\n        style={{ fontSize, height: h * 1.6, paddingBlock: h * 0.3, maskImage: mask, WebkitMaskImage: mask }}\n      >\n        <span className=\"sr-only\" aria-live=\"polite\">\n          {n}\n        </span>\n        <AnimatePresence initial={false}>\n          {places.map((p) => (\n            <motion.span\n              key={p}\n              aria-hidden\n              className=\"relative block\"\n              initial={{ width: 0, opacity: 0 }}\n              animate={{ width: \"auto\", opacity: 1 }}\n              exit={{ width: 0, opacity: 0 }}\n              transition={{ type: \"spring\", stiffness: 300, damping: 30 }}\n              style={{ height: h, clipPath: \"inset(-200% 0 -200% 0)\" }}\n            >\n              <Column place={p} value={n} height={h} />\n            </motion.span>\n          ))}\n        </AnimatePresence>\n      </div>\n      {showControls && (\n        <button type=\"button\" aria-label={`Increase by ${step}`} className={btn} disabled={n >= max} onClick={() => bump(1)}>\n          <Plus className=\"size-5\" aria-hidden />\n        </button>\n      )}\n    </div>\n  );\n}\n\nfunction Column({ place, value, height }: { place: number; value: number; height: number }) {\n  const reduce = useReducedMotion();\n  // Each column springs towards floor(value / 10^place), so 9 → 10 rolls both columns forward.\n  const target = Math.floor(value / 10 ** place);\n  const pos = useSpring(target, reduce ? { duration: 0 } : { stiffness: 120, damping: 20, mass: 0.8 });\n  useEffect(() => {\n    pos.set(target);\n  }, [pos, target]);\n\n  return (\n    <>\n      <span className=\"invisible block\" style={{ height, lineHeight: `${height}px` }}>\n        0\n      </span>\n      {Array.from({ length: 10 }, (_, d) => (\n        <Digit key={d} digit={d} pos={pos} height={height} />\n      ))}\n    </>\n  );\n}\n\nfunction Digit({ digit, pos, height }: { digit: number; pos: MotionValue<number>; height: number }) {\n  const y = useTransform(pos, (v) => {\n    let o = (((digit - v) % 10) + 10) % 10;\n    if (o > 5) o -= 10;\n    return o * height;\n  });\n  return (\n    <motion.span className=\"absolute inset-x-0 top-0 block text-center\" style={{ y, height, lineHeight: `${height}px` }}>\n      {digit}\n    </motion.span>\n  );\n}\n"}]}