{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"odometer","type":"registry:component","title":"Odometer","description":"Rolls forward like a mechanical counter and wraps from 999 back to 000.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/odometer.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport {\n  motion,\n  useSpring,\n  useTransform,\n  useVelocity,\n  type MotionValue,\n} from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nconst GLYPHS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];\n\nexport function Odometer({\n  value,\n  digits = 3,\n  className,\n}: {\n  value: number;\n  digits?: number;\n  className?: string;\n}) {\n  const modulo = 10 ** digits;\n  const shown = ((value % modulo) + modulo) % modulo;\n\n  return (\n    <div\n      className={cn(\n        \"inline-flex gap-1 rounded-[22px] bg-muted p-1.5 text-[64px] font-medium tracking-tight shadow-raised\",\n        className,\n      )}\n    >\n      <output className=\"sr-only\" aria-live=\"polite\">\n        {shown}\n      </output>\n      {Array.from({ length: digits }, (_, i) => {\n        const place = 10 ** (digits - 1 - i);\n        return <Wheel key={place} turns={Math.floor(value / place)} />;\n      })}\n    </div>\n  );\n}\n\n// `turns` never wraps, so a wheel going 9 to 10 keeps rolling forward onto 0\n// instead of spinning back through every digit.\nfunction Wheel({ turns }: { turns: number }) {\n  const reduceMotion = useReducedMotion();\n  const position = useSpring(turns, { visualDuration: 0.35, bounce: 0.15 });\n  const velocity = useVelocity(position);\n  const filter = useTransform(velocity, (v) => {\n    const blur = Math.min(Math.max((Math.abs(v) - 4) / 16, 0), 1) * 1.5;\n    return blur > 0 ? `blur(${blur}px)` : \"none\";\n  });\n\n  useEffect(() => {\n    if (reduceMotion) position.jump(turns);\n    else position.set(turns);\n  }, [turns, reduceMotion, position]);\n\n  return (\n    <div\n      aria-hidden\n      className=\"relative h-[1.4em] w-[0.8em] overflow-hidden rounded-2xl bg-background shadow-wheel\"\n    >\n      <motion.div\n        className=\"absolute inset-0 [mask-image:linear-gradient(transparent,black_20%,black_80%,transparent)]\"\n        style={{ filter: reduceMotion ? \"none\" : filter }}\n      >\n        {GLYPHS.map((digit) => (\n          <Glyph key={digit} digit={digit} position={position} />\n        ))}\n      </motion.div>\n    </div>\n  );\n}\n\nfunction Glyph({\n  digit,\n  position,\n}: {\n  digit: number;\n  position: MotionValue<number>;\n}) {\n  // Every glyph sits within five slots of the current position, so the wheel\n  // loops forever with only ten nodes. The jump from +5 to -5 happens off view.\n  const transform = useTransform(position, (p) => {\n    const offset = ((((digit - p) % 10) + 15) % 10) - 5;\n    return `translateY(${offset * 100}%)`;\n  });\n\n  return (\n    <motion.span\n      className=\"absolute inset-0 flex items-center justify-center tabular-nums\"\n      style={{ transform }}\n    >\n      {digit}\n    </motion.span>\n  );\n}\n\nfunction useHoldToRepeat(step: () => void) {\n  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);\n\n  const stop = useCallback(() => clearTimeout(timer.current), []);\n\n  const start = useCallback(() => {\n    stop();\n    step();\n    let delay = 120;\n    const tick = () => {\n      step();\n      delay = Math.max(25, delay * 0.9);\n      timer.current = setTimeout(tick, delay);\n    };\n    timer.current = setTimeout(tick, 400);\n  }, [step, stop]);\n\n  useEffect(() => stop, [stop]);\n\n  return { start, stop };\n}\n\nfunction StepButton({\n  label,\n  step,\n  children,\n}: {\n  label: string;\n  step: () => void;\n  children: React.ReactNode;\n}) {\n  const { start, stop } = useHoldToRepeat(step);\n\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      className=\"flex size-11 touch-manipulation items-center justify-center rounded-full bg-muted text-foreground shadow-raised transition-[scale] duration-150 ease-out select-none active:scale-[0.96] motion-reduce:transition-none\"\n      onPointerDown={(e) => {\n        if (e.button === 0) start();\n      }}\n      onPointerUp={stop}\n      onPointerLeave={stop}\n      onPointerCancel={stop}\n      onContextMenu={(e) => e.preventDefault()}\n      // Pointer presses are handled above; this only catches Enter and Space.\n      onClick={(e) => {\n        if (e.detail === 0) step();\n      }}\n    >\n      <svg\n        viewBox=\"0 0 16 16\"\n        className=\"size-4\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={1.5}\n        strokeLinecap=\"round\"\n      >\n        {children}\n      </svg>\n    </button>\n  );\n}\n\nexport default function OdometerDemo() {\n  const [count, setCount] = useState(0);\n  const increment = useCallback(() => setCount((c) => c + 1), []);\n  const decrement = useCallback(() => setCount((c) => c - 1), []);\n\n  return (\n    <div className=\"flex flex-col items-center gap-6\">\n      <Odometer value={count} />\n      <div className=\"flex gap-3\">\n        <StepButton label=\"Decrease\" step={decrement}>\n          <path d=\"M3.5 8h9\" />\n        </StepButton>\n        <StepButton label=\"Increase\" step={increment}>\n          <path d=\"M3.5 8h9M8 3.5v9\" />\n        </StepButton>\n      </div>\n    </div>\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)","shadow-wheel":"inset 0 1px 2px oklch(0 0 0 / 0.08), inset 0 0 0 1px oklch(0 0 0 / 0.04)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)","shadow-wheel":"inset 0 1px 2px oklch(0 0 0 / 0.5), inset 0 0 0 1px oklch(1 0 0 / 0.04)"},"theme":{"shadow-raised":"var(--shadow-raised)","shadow-wheel":"var(--shadow-wheel)"}},"categories":["data"],"docs":"From ui lab: https://lab.xevrion.dev/lab/odometer"}