{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"odometer","type":"registry:component","title":"Odometer","description":"Numbers that roll digit by digit, like an odometer.","author":"DiMaac (HankTheTank) <https://dimaac.com>","dependencies":["gsap"],"files":[{"path":"registry/dimaac/odometer/odometer.tsx","type":"registry:component","target":"components/dimaac/odometer.tsx","content":"\"use client\";\n\nimport { useLayoutEffect, useRef, useState } from \"react\";\nimport { gsap } from \"gsap\";\n\n// Numbers that roll digit by digit, like the wheels of an odometer. Every digit is a window one\n// character tall with a tall strip of 0-9 behind it; rolling is just sliding that strip up.\n// The strip is 40 digits long (four full 0-9 turns) so a digit can spin around a few times before\n// it lands. Separators, the prefix and the suffix stay still while the wheels turn.\n\nconst CELLS = 40;\n\nexport type OdometerProps = {\n  /** The number to show once the wheels stop. */\n  value?: number;\n  /** The number the wheels start from the first time they roll. */\n  from?: number;\n  /** How long one wheel takes to roll, in seconds. */\n  duration?: number;\n  /** How long each wheel waits after the one on its left, in seconds. */\n  stagger?: number;\n  /** Text before the number, e.g. \"$\". */\n  prefix?: string;\n  /** Text after the number, e.g. \"%\" or \" subscribers\". */\n  suffix?: string;\n  /** How many digits to show after the decimal mark. */\n  decimals?: number;\n  /** What goes between groups of thousands, e.g. \",\" or \" \". Empty for none. */\n  separator?: string;\n  /** The decimal mark, e.g. \".\" or \",\". */\n  decimalMark?: string;\n  /** When it rolls: \"inView\" the first time it scrolls into view, \"load\" right away, \"change\" only when the value changes (it starts out showing the value). A new value always rolls from what is on screen. */\n  trigger?: \"inView\" | \"load\" | \"change\";\n  /** The GSAP ease of each wheel, e.g. \"power3.out\" or \"expo.out\". */\n  ease?: string;\n  /** Extra classes for the outer box. Set the font size, weight and colour here; everything is sized in em. */\n  className?: string;\n  /** Extra classes for the prefix and the suffix, e.g. \"opacity-50\" or \"text-[0.5em]\". */\n  affixClassName?: string;\n};\n\ntype Piece = { kind: \"digit\"; digit: number; fromRight: number } | { kind: \"text\"; char: string };\n\n// \"12480\" → \"12,480\" as a list of digit wheels and fixed characters, left to right\nfunction buildPieces(value: number, decimals: number, separator: string, decimalMark: string): Piece[] {\n  const places = Math.min(6, Math.max(0, Math.round(decimals)));\n  const [whole = \"0\", fraction = \"\"] = Math.abs(value).toFixed(places).split(\".\");\n  const grouped = separator ? whole.replace(/\\B(?=(\\d{3})+(?!\\d))/g, \"\\u0000\") : whole;\n  const characters = [...(value < 0 ? \"-\" : \"\"), ...grouped, ...(places > 0 ? [\"\\u0001\", ...fraction] : [])];\n  const digitCount = characters.filter((c) => c >= \"0\" && c <= \"9\").length;\n  let seen = 0;\n  return characters.map((c): Piece => {\n    if (c >= \"0\" && c <= \"9\") {\n      seen += 1;\n      return { kind: \"digit\", digit: Number(c), fromRight: digitCount - seen };\n    }\n    return { kind: \"text\", char: c === \"\\u0000\" ? separator : c === \"\\u0001\" ? decimalMark : c };\n  });\n}\n\n// The digits of `from`, left-padded with zeros (or trimmed) to fit the wheels we have\nfunction startDigits(from: number, decimals: number, count: number): number[] {\n  const places = Math.min(6, Math.max(0, Math.round(decimals)));\n  const digits = Math.abs(from).toFixed(places).replace(\".\", \"\").split(\"\").map(Number);\n  while (digits.length < count) digits.unshift(0);\n  return digits.slice(digits.length - count);\n}\n\n// The wheels fade out at the top and bottom edge of their window, so digits slide in softly.\n// The digit at rest sits in the clear middle.\nconst fade = { maskImage: \"linear-gradient(to bottom, transparent, #000 7%, #000 93%, transparent)\", WebkitMaskImage: \"linear-gradient(to bottom, transparent, #000 7%, #000 93%, transparent)\" };\n\nconst wrap = (n: number) => ((n % 10) + 10) % 10;\nconst percentFor = (index: number) => -(index / CELLS) * 100;\n\nexport function Odometer({\n  value = 12480,\n  from = 0,\n  duration = 1.6,\n  stagger = 0.06,\n  prefix = \"\",\n  suffix = \"\",\n  decimals = 0,\n  separator = \",\",\n  decimalMark = \".\",\n  trigger = \"inView\",\n  ease = \"power3.out\",\n  className = \"\",\n  affixClassName = \"\",\n}: OdometerProps) {\n  const boxRef = useRef<HTMLSpanElement>(null);\n  // The number is drawn as plain text on the server and until the first layout effect, so it\n  // is readable without JavaScript and never flashes the wrong digits\n  const [ready, setReady] = useState(false);\n  // False until the first full roll has finished. Until then every wheel spins, even a 0 that stays 0.\n  const firstRollPending = useRef(true);\n\n  const pieces = buildPieces(value, decimals, separator, decimalMark);\n  const plain = pieces.map((p) => (p.kind === \"digit\" ? p.digit : p.char)).join(\"\");\n  const spoken = `${prefix}${plain}${suffix}`;\n  const digitCount = pieces.filter((p) => p.kind === \"digit\").length;\n  // New format = new wheels (a different number of digits, separator or decimals)\n  const layoutKey = `${digitCount}|${decimals}|${separator}|${decimalMark}`;\n\n  useLayoutEffect(() => setReady(true), []);\n\n  useLayoutEffect(() => {\n    const box = boxRef.current;\n    if (!ready || !box) return;\n    const strips = Array.from(box.querySelectorAll<HTMLElement>(\"[data-wheel]\"));\n    if (strips.length === 0) return;\n\n    const end = pieces.filter((p) => p.kind === \"digit\").map((p) => p.digit);\n    const start = startDigits(from, decimals, end.length);\n    const indexOf = (strip: HTMLElement) => (-Number(gsap.getProperty(strip, \"yPercent\")) / 100) * CELLS;\n    const show = (strip: HTMLElement, index: number) => gsap.set(strip, { yPercent: percentFor(index) });\n\n    // Wheels that were just created sit on their start digit (or on the value, if it only rolls on change)\n    const fresh = strips.some((strip) => !strip.dataset.set);\n    if (fresh) {\n      strips.forEach((strip, k) => {\n        strip.dataset.set = \"1\";\n        show(strip, trigger === \"change\" ? end[k]! : start[k]!);\n      });\n      firstRollPending.current = trigger !== \"change\";\n    }\n\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    if (reduced) {\n      strips.forEach((strip, k) => show(strip, end[k]!));\n      firstRollPending.current = false;\n      return;\n    }\n\n    let timeline: gsap.core.Timeline | null = null;\n    let observer: IntersectionObserver | null = null;\n\n    const roll = () => {\n      const forced = firstRollPending.current;\n      const current = strips.map((strip) => wrap(indexOf(strip)));\n      const distance = end.map((digit, k) => wrap(digit - current[k]!));\n      // Like a real odometer, every wheel to the right of the first one that changes turns too\n      let first = distance.findIndex((d) => d > 0.001);\n      if (forced) first = 0;\n      if (first === -1) return;\n\n      timeline = gsap.timeline({\n        onComplete: () => {\n          firstRollPending.current = false;\n        },\n      });\n      strips.forEach((strip, k) => {\n        if (k < first) return;\n        const fromRight = strips.length - 1 - k;\n        // How many extra full turns this wheel makes: the right-most wheels spin the most\n        const turns = forced ? (fromRight === 0 ? 2 : 1) : distance[k]! > 0.001 ? (fromRight === 0 && k > first ? 1 : 0) : 1;\n        const travel = distance[k]! + turns * 10;\n        show(strip, current[k]!);\n        timeline!.to(\n          strip,\n          {\n            yPercent: percentFor(current[k]! + travel),\n            duration,\n            ease,\n            // Back onto the first 0-9 so the strip never runs out\n            onComplete: () => show(strip, end[k]!),\n          },\n          (k - first) * stagger,\n        );\n      });\n    };\n\n    if (firstRollPending.current && trigger === \"inView\") {\n      observer = new IntersectionObserver(\n        ([entry]) => {\n          if (!entry?.isIntersecting) return;\n          observer?.disconnect();\n          roll();\n        },\n        { threshold: 0.3 },\n      );\n      observer.observe(box);\n    } else {\n      roll();\n    }\n\n    return () => {\n      observer?.disconnect();\n      timeline?.kill();\n    };\n    // `pieces` changes every render but plain/layoutKey cover everything that matters in it\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [ready, plain, layoutKey, from, trigger, duration, stagger, ease]);\n\n  const affix = (text: string) =>\n    text ? (\n      <span aria-hidden className={`whitespace-pre leading-none ${affixClassName}`}>\n        {text}\n      </span>\n    ) : null;\n\n  return (\n    <span ref={boxRef} className={`inline-flex items-center leading-none whitespace-pre ${className}`} style={{ fontVariantNumeric: \"tabular-nums\", fontFeatureSettings: '\"tnum\"' }}>\n      <span className=\"sr-only\">{spoken}</span>\n      {affix(prefix)}\n      <span key={ready ? layoutKey : \"static\"} aria-hidden className=\"inline-flex items-center leading-none\">\n        {ready\n          ? pieces.map((piece, i) =>\n              piece.kind === \"text\" ? (\n                <span key={`t${i}`} className=\"inline-block leading-none\">\n                  {piece.char}\n                </span>\n              ) : (\n                // The window: one digit tall. The 0-9 strip slides behind it.\n                <span key={`d${piece.fromRight}`} className=\"-mx-[0.04em] inline-block h-[1em] overflow-hidden px-[0.04em] leading-none\" style={fade}>\n                  <span data-wheel className=\"block will-change-transform\">\n                    {Array.from({ length: CELLS }, (_, n) => (\n                      <span key={n} className=\"block h-[1em] leading-none\">\n                        {n % 10}\n                      </span>\n                    ))}\n                  </span>\n                </span>\n              ),\n            )\n          : plain}\n      </span>\n      {affix(suffix)}\n    </span>\n  );\n}\n"}],"categories":["text","gsap","counter","numbers","stats"],"docs":"import { Odometer } from \"@/components/dimaac/odometer\". Give its parent a size: it fills it. Docs, playground and props: https://ui.dimaac.com/components/odometer","meta":{"tier":"free","preview":"https://ui.dimaac.com/components/odometer","credits":[]}}