{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"odometer-counter","type":"registry:component","title":"Odometer Counter","description":"A mechanical odometer: every digit is a drum that spins, blurs and glows as it rolls, then settles with a detent bounce. Counts up once in view, rolls up or down when the number changes, and slides columns and thousands separators in as it grows. No dependencies.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/odometer-counter.tsx","content":"\"use client\";\n\nimport { Fragment, useEffect, useRef, useState } from \"react\";\nimport type { CSSProperties } from \"react\";\n\n/**\n * OdometerCounter - a mechanical odometer.\n *\n * Every digit is its own drum: a strip of 0-9 behind a dark glass window.\n * The number counts up when it scrolls into view (once), the fast drums\n * blur and glow while they spin, then each drum settles with a small\n * detent bounce, the low ones first. Change `target` later and it rolls\n * the right way: up for more, down for less. Higher digits only turn when\n * the drum beside them carries over, columns slide in as the number grows\n * and thousands separators slide in with them.\n *\n * Size comes from the CSS variable --odo-fs (default 2.25rem); the whole\n * thing is built in em. Respects prefers-reduced-motion. React only.\n */\n\nconst CELL = 1.2; // drum window height, em\nconst COL_W = 0.64; // drum width, em\nconst GAP = 0.06; // space between drums, em\nconst SEP_W = 0.3; // thousands separator slot, em\nconst MAX_COLS = 16;\nconst HEX = /^#[0-9a-fA-F]{6}$/;\n// 0-9 then 0 again, so any position 0 <= p < 10 shows a full window.\nconst STRIP = [\"0\", \"1\", \"2\", \"3\", \"4\", \"5\", \"6\", \"7\", \"8\", \"9\", \"0\"];\n\nfunction rgba(hex: string, a: number): string {\n  const n = parseInt(hex.slice(1), 16);\n  return \"rgba(\" + ((n >> 16) & 255) + \",\" + ((n >> 8) & 255) + \",\" + (n & 255) + \",\" + a + \")\";\n}\n\nfunction smooth(a: number, b: number, x: number): number {\n  const t = Math.min(1, Math.max(0, (x - a) / (b - a)));\n  return t * t * (3 - 2 * t);\n}\n\n/** Drum position in digits: the units drum is continuous, a higher drum only turns while the one below it carries 9 to 0. */\nfunction drumAt(v: number, k: number): number {\n  const u = v / Math.pow(10, k);\n  if (k === 0) return u;\n  const fl = Math.floor(u);\n  return fl + smooth(0.88, 1, u - fl);\n}\n\nfunction grouped(n: number, sep: string): string {\n  const s = String(Math.abs(n));\n  let out = \"\";\n  for (let i = 0; i < s.length; i++) {\n    if (i > 0 && (s.length - i) % 3 === 0) out += sep;\n    out += s.charAt(i);\n  }\n  return (n < 0 ? \"-\" : \"\") + out;\n}\n\ntype Drum = {\n  e: number; // detent spring offset, digits\n  ev: number;\n  a: number; // presence 0..1\n  prev: number;\n  heat: number;\n  kickAt: number;\n  kickV: number;\n};\n\nexport type OdometerCounterProps = {\n  /** The number to roll to. Change it later and the drums roll up or down to it. */\n  target?: number;\n  /** Seconds for the first count from zero. */\n  duration?: number;\n  /** Printed before the number, dimmed. */\n  prefix?: string;\n  /** Printed after the number in the accent colour. */\n  suffix?: string;\n  /** Thousands separator. */\n  separator?: string;\n  /** Accent as #rrggbb: suffix glow and the heat on a spinning drum. */\n  accent?: string;\n  className?: string;\n  style?: CSSProperties;\n};\n\nexport function OdometerCounter({\n  target = 12400,\n  duration = 1.6,\n  prefix = \"\",\n  suffix = \"+\",\n  separator = \",\",\n  accent = \"#ff5c3c\",\n  className = \"\",\n  style,\n}: OdometerCounterProps) {\n  const goal = Number.isFinite(target) ? Math.round(target) : 0;\n  const need = Math.min(MAX_COLS, String(Math.abs(goal)).length);\n  const [cols, setCols] = useState(need);\n  if (need > cols) setCols(need);\n  const acc = HEX.test(accent) ? accent : \"#ff5c3c\";\n\n  const rootRef = useRef<HTMLSpanElement>(null);\n  const boxes = useRef<(HTMLSpanElement | null)[]>([]);\n  const strips = useRef<(HTMLSpanElement | null)[]>([]);\n  const heats = useRef<(HTMLSpanElement | null)[]>([]);\n  const seps = useRef<(HTMLSpanElement | null)[]>([]);\n  const signBox = useRef<HTMLSpanElement>(null);\n  const cfg = useRef({ goal, duration });\n  const wake = useRef<(() => void) | null>(null);\n\n  useEffect(() => {\n    cfg.current = { goal, duration };\n    wake.current?.();\n  }, [goal, duration]);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const drums: Drum[] = Array.from({ length: MAX_COLS }, () => ({\n      e: 0, ev: 0, a: 0, prev: 0, heat: 0, kickAt: 0, kickV: 0,\n    }));\n    let alive = true;\n    let started = false;\n    let first = true;\n    let applied: number | null = null;\n    let cur = 0;\n    let signA = 0;\n    let raf = 0;\n    let last = 0;\n    let tw: { from: number; to: number; t0: number; dur: number } | null = null;\n\n    const finish = (t: { from: number; to: number }, now: number) => {\n      const early = t.from + (t.to - t.from) * (1 - Math.pow(0.4, 4));\n      const dir = Math.abs(t.to) >= Math.abs(t.from) ? 1 : -1;\n      drums.forEach((d, k) => {\n        const mov = Math.abs(drumAt(Math.abs(t.to), k) - drumAt(Math.abs(early), k));\n        if (mov > 0.02) {\n          d.kickAt = now + k * 55;\n          d.kickV = (dir * Math.min(1, mov) * 2.6) / (1 + 0.45 * k);\n        }\n      });\n    };\n\n    const frame = (now: number) => {\n      raf = 0;\n      if (!alive) return;\n      const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;\n      last = now;\n      const c = cfg.current;\n      const reduced = mq.matches;\n\n      if (started && applied !== c.goal) {\n        const from = cur;\n        applied = c.goal;\n        if (reduced) {\n          cur = c.goal;\n          tw = null;\n        } else {\n          const dist = Math.abs(c.goal - from);\n          const dur = first ? Math.max(0.2, c.duration) : Math.min(1.6, Math.max(0.7, 0.7 + 0.2 * Math.log10(1 + dist)));\n          tw = { from, to: c.goal, t0: now, dur };\n        }\n        first = false;\n      }\n\n      if (tw) {\n        const t = (now - tw.t0) / (1000 * tw.dur);\n        if (t >= 1) {\n          cur = tw.to;\n          finish(tw, now);\n          tw = null;\n        } else {\n          const q = 1 - t;\n          cur = tw.from + (tw.to - tw.from) * (1 - q * q * q * q);\n        }\n      }\n\n      const av = Math.abs(cur);\n      let busy = tw !== null;\n      for (let k = 0; k < MAX_COLS; k++) {\n        const d = drums[k];\n        const strip = strips.current[k];\n        if (!d || !strip) continue;\n        if (reduced) {\n          d.e = 0;\n          d.ev = 0;\n          d.kickAt = 0;\n        }\n        if (d.kickAt && now >= d.kickAt) {\n          d.ev += d.kickV;\n          d.kickAt = 0;\n        }\n        if (d.kickAt) busy = true;\n        for (let s = 0; s < 2; s++) {\n          const h = dt / 2;\n          d.ev += (-140 * d.e - 11 * d.ev) * h;\n          d.e += d.ev * h;\n        }\n        if (Math.abs(d.e) < 0.0007 && Math.abs(d.ev) < 0.004) {\n          d.e = 0;\n          d.ev = 0;\n        } else busy = true;\n\n        const pt = drumAt(av, k);\n        const pd = pt + d.e;\n        const speed = Math.abs(pt - d.prev) / dt;\n        d.prev = pt;\n        const y = ((pd % 10) + 10) % 10;\n        strip.style.transform = \"translate3d(0,\" + (-y * CELL).toFixed(4) + \"em,0)\";\n        const b = smooth(12, 72, speed) * 0.032;\n        strip.style.filter = b > 0.001 ? \"blur(\" + b.toFixed(4) + \"em)\" : \"none\";\n\n        d.heat += (Math.min(1, speed / 40) - d.heat) * (1 - Math.exp(-12 * dt));\n        if (d.heat < 0.01) d.heat = 0;\n        else busy = true;\n        const heat = heats.current[k];\n        if (heat) heat.style.opacity = d.heat.toFixed(3);\n\n        const at = k === 0 || Math.floor(av) >= Math.pow(10, k) ? 1 : 0;\n        if (reduced) d.a = at;\n        else d.a += (at - d.a) * (1 - Math.exp(-16 * dt));\n        if (Math.abs(at - d.a) < 0.003) d.a = at;\n        else busy = true;\n        const box = boxes.current[k];\n        if (box) {\n          box.style.width = (d.a * (COL_W + GAP)).toFixed(4) + \"em\";\n          box.style.opacity = d.a.toFixed(3);\n        }\n        const sep = seps.current[k];\n        if (sep) {\n          sep.style.width = (d.a * SEP_W).toFixed(4) + \"em\";\n          sep.style.opacity = d.a.toFixed(3);\n        }\n      }\n      const sg = signBox.current;\n      if (sg) {\n        const at = cur < 0 ? 1 : 0;\n        if (reduced) signA = at;\n        else signA += (at - signA) * (1 - Math.exp(-16 * dt));\n        if (Math.abs(at - signA) < 0.003) signA = at;\n        else busy = true;\n        sg.style.width = (signA * 0.42).toFixed(4) + \"em\";\n        sg.style.opacity = signA.toFixed(3);\n      }\n      if (busy) raf = requestAnimationFrame(frame);\n    };\n\n    const go = () => {\n      if (!alive || raf) return;\n      last = 0;\n      raf = requestAnimationFrame(frame);\n    };\n    wake.current = go;\n\n    let io: IntersectionObserver | null = null;\n    const begin = () => {\n      started = true;\n      go();\n    };\n    if (typeof IntersectionObserver === \"undefined\") begin();\n    else {\n      io = new IntersectionObserver(\n        (entries) => {\n          if (entries.some((en) => en.isIntersecting)) {\n            begin();\n            io?.disconnect();\n          }\n        },\n        { threshold: 0.6 },\n      );\n      io.observe(root);\n    }\n    mq.addEventListener(\"change\", go);\n    return () => {\n      alive = false;\n      wake.current = null;\n      if (raf) cancelAnimationFrame(raf);\n      io?.disconnect();\n      mq.removeEventListener(\"change\", go);\n    };\n  }, []);\n\n  const order = Array.from({ length: cols }, (_, i) => cols - 1 - i);\n  const tile: CSSProperties = {\n    position: \"relative\",\n    flex: \"none\",\n    width: COL_W + \"em\",\n    height: CELL + \"em\",\n    margin: \"0 \" + GAP / 2 + \"em\",\n    overflow: \"hidden\",\n    borderRadius: \"0.16em\",\n    background: \"linear-gradient(180deg,#1b1d22 0%,#0e0f12 52%,#181a1e 100%)\",\n    boxShadow: \"inset 0 0 0 1px rgba(255,255,255,0.09), inset 0 1px 0 rgba(255,255,255,0.14)\",\n    color: \"#f5f6f8\",\n    fontWeight: 650,\n    letterSpacing: \"-0.02em\",\n  };\n\n  return (\n    <span\n      ref={rootRef}\n      className={\"relative inline-flex font-black tabular-nums \" + className}\n      style={{ fontSize: \"var(--odo-fs, 2.25rem)\", lineHeight: 1, fontVariantNumeric: \"tabular-nums\", ...style }}\n    >\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {prefix + grouped(goal, separator) + suffix}\n      </span>\n      <span aria-hidden=\"true\" style={{ display: \"inline-flex\", alignItems: \"center\" }}>\n        {prefix ? (\n          <span style={{ opacity: 0.6, fontSize: \"0.62em\", marginRight: \"0.18em\", fontWeight: 600 }}>{prefix}</span>\n        ) : null}\n        <span ref={signBox} style={{ display: \"flex\", justifyContent: \"center\", overflow: \"hidden\", width: 0, opacity: 0 }}>\n          <span style={{ flex: \"none\", opacity: 0.7, fontWeight: 600 }}>-</span>\n        </span>\n        {order.map((k) => (\n          <Fragment key={k}>\n            <span\n              ref={(el) => {\n                boxes.current[k] = el;\n              }}\n              style={{\n                display: \"flex\",\n                justifyContent: \"center\",\n                flex: \"none\",\n                overflow: \"hidden\",\n                height: CELL + \"em\",\n                width: k === 0 ? COL_W + GAP + \"em\" : 0,\n                opacity: k === 0 ? 1 : 0,\n              }}\n            >\n              <span style={tile}>\n                <span\n                  ref={(el) => {\n                    strips.current[k] = el;\n                  }}\n                  style={{ position: \"absolute\", left: 0, top: 0, width: \"100%\", willChange: \"transform\" }}\n                >\n                  {STRIP.map((d, i) => (\n                    <span key={i} style={{ display: \"block\", height: CELL + \"em\", lineHeight: CELL + \"em\", textAlign: \"center\" }}>\n                      {d}\n                    </span>\n                  ))}\n                </span>\n                <span\n                  ref={(el) => {\n                    heats.current[k] = el;\n                  }}\n                  style={{\n                    position: \"absolute\",\n                    inset: 0,\n                    opacity: 0,\n                    background: \"radial-gradient(120% 60% at 50% 100%,\" + rgba(acc, 0.4) + \",transparent 70%)\",\n                  }}\n                />\n                <span\n                  style={{\n                    position: \"absolute\",\n                    inset: 0,\n                    background:\n                      \"linear-gradient(180deg,rgba(0,0,0,0.8) 0%,rgba(0,0,0,0) 27%,rgba(0,0,0,0) 73%,rgba(0,0,0,0.8) 100%),linear-gradient(180deg,rgba(255,255,255,0.07),rgba(255,255,255,0) 42%)\",\n                  }}\n                />\n              </span>\n            </span>\n            {k > 0 && k % 3 === 0 ? (\n              <span\n                ref={(el) => {\n                  seps.current[k] = el;\n                }}\n                style={{\n                  display: \"flex\",\n                  justifyContent: \"center\",\n                  alignItems: \"flex-end\",\n                  flex: \"none\",\n                  overflow: \"hidden\",\n                  height: CELL + \"em\",\n                  paddingBottom: \"0.2em\",\n                  width: 0,\n                  opacity: 0,\n                }}\n              >\n                <span style={{ flex: \"none\", opacity: 0.5 }}>{separator}</span>\n              </span>\n            ) : null}\n          </Fragment>\n        ))}\n        {suffix ? (\n          <span\n            style={{\n              marginLeft: \"0.1em\",\n              fontSize: \"0.78em\",\n              color: acc,\n              textShadow: \"0 0 0.6em \" + rgba(acc, 0.55),\n            }}\n          >\n            {suffix}\n          </span>\n        ) : null}\n      </span>\n    </span>\n  );\n}\n","type":"registry:component","target":"components/viberdy/odometer-counter.tsx"}],"meta":{"tier":"free","tags":["counter","number","scroll","stats","odometer","count up","digits","spring"],"href":"https://viberdy.dev/library/odometer-counter","aiPrompt":"Create a single-file React component named OdometerCounter (a \"use client\" file, React only, no animation library, styles inline so it works anywhere). Size comes from the CSS variable --odo-fs (default 2.25rem) and everything inside is in em.\n\nProps (all optional): target 12400 (whole number to roll to; changing it later rolls the drums up or down to it), duration 1.6 (seconds for the first count from zero), prefix \"\" (62% size, 60% opacity, inherits the text colour so it works on light and dark pages), suffix \"+\" (78% size, in the accent colour with a 0.6em glow), separator \",\", accent \"#ff5c3c\" (validated #rrggbb, else the default), className, style.\n\nStructure: root inline-flex span (font-black, tabular-nums, line-height 1) > [sr-only role=status aria-live=polite span holding prefix + grouped(target) + suffix, and an aria-hidden row]. The row holds a sign slot (\"-\", only when the value is negative), then one column per digit of the target from the highest place to the units (the column count only grows), a thousands separator slot after each column whose place is 3, 6, 9..., then the suffix.\nEach column: an overflow-hidden box (width and opacity animated) > a tile 0.64em wide x 1.2em tall, radius 0.16em, margin 0 0.03em, background linear-gradient(180deg,#1b1d22,#0e0f12 52%,#181a1e), inset 1px ring rgba(255,255,255,0.09) plus a top inset highlight rgba(255,255,255,0.14), white text weight 650, tracking -0.02em. Inside the tile: a strip of 11 cells (0-9 then 0 again, each 1.2em tall, centred), a heat layer (radial-gradient 120% 60% at 50% 100%, accent at 0.4 alpha, opacity driven by speed) and a drum shade (linear-gradient black 0.8 alpha at 0, transparent from 27% to 73%, black 0.8 at 100%, plus a white 0.07 to 0 gradient over the top 42%).\n\nMotion in ONE requestAnimationFrame loop inside a mount effect that writes to the DOM and sleeps when everything is at rest. It starts when the root is 60% in view (IntersectionObserver, threshold 0.6, once; start immediately if IntersectionObserver is missing). A tween drives the value: first run from 0 over `duration` with ease-out quartic (1 - (1-t)^4); later target changes tween from the current value over clamp(0.7 + 0.2*log10(1 + distance), 0.7, 1.6) seconds. Drum position for place k: u = value / 10^k; k = 0 uses u, k >= 1 uses floor(u) + smoothstep(0.88, 1, frac(u)) so a higher drum only turns during the carry. Strip translateY = -(position mod 10) * 1.2em (mod wrapped positive, so rolling down works). Speed (digits per second) drives blur, smoothstep(12, 72, speed) * 0.032em, and heat = min(1, speed/40) smoothed at rate 12/s. Detent: when the tween ends, each column that moved in the last 40% of the tween gets a velocity kick of 2.6 * min(1, moved) / (1 + 0.45k) digits per second in the roll direction, starting k*55 ms after the end (low drums first), on a spring with stiffness 140 and damping 11 (damping ratio about 0.47, roughly a tenth of a digit of overshoot). Presence: a column or separator is open when floor(value) >= 10^k (the units column is always open); width and opacity ease toward it at rate 16/s (width = presence * (0.64 + 0.06)em, separator slot 0.3em, sign slot 0.42em). Before it is seen only the units drum shows a 0.\n\nprefers-reduced-motion: no tween, no blur, no bounce, columns snap, the number shows its final value as soon as it is in view. Without JS the units drum shows 0 and the sr-only text carries the real number.","license":"https://viberdy.dev/license"}}