{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "odometer",
  "type": "registry:block",
  "title": "Spring Odometer",
  "description": "A rolling counter where every digit is its own wheel on its own spring: carries ripple leftwards, big jumps spin the lower wheels extra turns, and a moving wheel lights up and smears with a vertical motion blur. Locale separators, decimals and new columns that slide open.",
  "docs": "Spring Odometer was added to public/vividsites/odometer.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/odometer",
  "categories": [
    "ui",
    "text-type"
  ],
  "files": [
    {
      "path": "components/vividsites/odometer.html",
      "type": "registry:file",
      "target": "public/vividsites/odometer.html",
      "content": "<!--\n  vs-odometer: a rolling counter where every digit is its own wheel on its own spring. Small steps\n  roll only the digits that change, carries ripple leftwards like a mechanical counter, big jumps spin\n  the lower wheels a few extra turns, and a wheel lights up and smears with speed while it moves\n  (a vertical-only motion blur). New digit columns slide open, and grouping\n  separators follow the locale. Interrupting a roll carries each wheel's velocity into the next.\n\n  Markup: <span class=\"vs-odometer\" data-vs-odometer=\"48210\">48,210</span>\n    data-vs-odometer            the value (defaults to the element's text)\n    data-vs-odometer-decimals   fixed decimals (0)\n    data-vs-odometer-locale     Intl locale for separators (browser default)\n    data-vs-odometer-from       value it rolls up from when it first scrolls into view (0; \"none\" to skip)\n  API: el.vsOdometer.set(value) → rolls to the new value. el.vsOdometer.value reads it.\n  The live value is exposed to screen readers through aria-label; the wheels are aria-hidden.\n\n  Variables:\n    --vs-odometer-active      colour of a wheel while it spins  (#5ef0c8)\n    --vs-odometer-tension     wheel spring tension              (170)\n    --vs-odometer-friction    wheel spring friction (lower = more detent bounce) (19)\n    --vs-odometer-stagger     delay per column, right to left  (45ms)\n    --vs-odometer-trail       motion-blur strength, 0 = none    (1)\n  Variables are read on every set(), so they can change live.\n  Use tabular figures; any face works. prefers-reduced-motion: digits change in place.\n-->\n<style>\n  .vs-odometer {\n    --vs-odometer-active: #5ef0c8;\n    --vs-odometer-tension: 170;\n    --vs-odometer-friction: 19;\n    --vs-odometer-stagger: 45ms;\n    --vs-odometer-trail: 1;\n    display: inline-flex; align-items: flex-start; line-height: 1; white-space: nowrap;\n    font-variant-numeric: tabular-nums; font-feature-settings: \"tnum\" 1;\n  }\n  /* each wheel's window is 1.24em tall (so nothing is cropped) but takes 1em of layout */\n  .vs-odometer-col {\n    position: relative; display: inline-block; height: 1.24em; margin: -.12em 0; overflow: hidden;\n    /* soft edges: digits fade as they roll out of the window */\n    -webkit-mask-image: linear-gradient(180deg, transparent 0, #000 11%, #000 89%, transparent 100%);\n            mask-image: linear-gradient(180deg, transparent 0, #000 11%, #000 89%, transparent 100%);\n    transition: color 500ms cubic-bezier(.16,1,.3,1);\n  }\n  .vs-odometer-col.is-spinning { color: var(--vs-odometer-active); transition: color 120ms linear; }\n  .vs-odometer-strip { display: flex; flex-direction: column; will-change: transform; }\n  .vs-odometer-strip > span { display: block; height: 1.24em; line-height: 1.24; text-align: center; }\n  .vs-odometer-sep { display: inline-block; overflow: hidden; }\n  .vs-odometer-sr { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }\n</style>\n<script>\n(() => {\n  if (window.VsOdometer) return;\n  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;\n  const cssNum = (el, n, fb) => { const v = parseFloat(getComputedStyle(el).getPropertyValue(n)); return isNaN(v) ? fb : v; };\n  // three shared vertical motion-blur strengths\n  const defs = () => {\n    if (document.getElementById('vs-odometer-mb1')) return;\n    const d = document.createElement('div'); d.setAttribute('aria-hidden', 'true'); d.style.cssText = 'position:absolute;width:0;height:0;overflow:hidden;pointer-events:none';\n    d.innerHTML = '<svg width=\"0\" height=\"0\" style=\"position:absolute\"><filter id=\"vs-odometer-mb1\" x=\"0\" y=\"-20%\" width=\"100%\" height=\"140%\"><feGaussianBlur stdDeviation=\"0 1.2\"/></filter><filter id=\"vs-odometer-mb2\" x=\"0\" y=\"-20%\" width=\"100%\" height=\"140%\"><feGaussianBlur stdDeviation=\"0 2.6\"/></filter><filter id=\"vs-odometer-mb3\" x=\"0\" y=\"-20%\" width=\"100%\" height=\"140%\"><feGaussianBlur stdDeviation=\"0 4.5\"/></filter></svg>';\n    document.body.appendChild(d);\n  };\n\n  function mount(el) {\n    defs();\n    if (el.vsOdometer) return el.vsOdometer;\n    const dec = parseInt(el.dataset.vsOdometerDecimals || '0', 10);\n    const fmt = new Intl.NumberFormat(el.dataset.vsOdometerLocale || undefined, { minimumFractionDigits: dec, maximumFractionDigits: dec });\n    const parse = (s) => parseFloat(String(s).replace(/[^\\d.-]/g, '')) || 0;\n    const target = el.dataset.vsOdometer != null && el.dataset.vsOdometer !== '' ? parse(el.dataset.vsOdometer) : parse(el.textContent);\n    const cfg = {};\n    const readCfg = () => Object.assign(cfg, { k: cssNum(el, '--vs-odometer-tension', 170), c: cssNum(el, '--vs-odometer-friction', 19),\n      stagger: cssNum(el, '--vs-odometer-stagger', 45), trail: cssNum(el, '--vs-odometer-trail', 1) });\n    readCfg();\n\n    el.textContent = '';\n    const vis = document.createElement('span'); vis.setAttribute('aria-hidden', 'true'); vis.style.display = 'inline-flex';\n    el.appendChild(vis);\n    el.setAttribute('role', el.getAttribute('role') || 'img');\n\n    // columns keyed by place value (0 = ones, -1 = tenths …), so digits keep their wheel when the length changes\n    const cols = new Map();\n    let value = 0, raf = 0, last = 0;\n\n    const makeCol = (place) => {\n      const col = document.createElement('span'); col.className = 'vs-odometer-col';\n      const strip = document.createElement('span'); strip.className = 'vs-odometer-strip';\n      for (let d = 0; d <= 10; d++) { const s = document.createElement('span'); s.textContent = d % 10; strip.appendChild(s); }\n      col.appendChild(strip);\n      return { place, col, strip, p: 0, v: 0, goal: 0, due: 0, w: 0, wv: 0, wGoal: 1, sep: null };\n    };\n\n    // render the formatted string's structure: digits map to wheels, everything else to static separators\n    function layout(str) {\n      const chars = [...str];\n      let place = str.replace(/\\D/g, '').length - dec - 1;   // place value of the first digit\n      const order = [];\n      chars.forEach((ch, i) => {\n        if (/\\d/.test(ch)) { order.push({ type: 'd', place }); place--; }\n        else order.push({ type: 's', ch });\n      });\n      // rebuild DOM order, reusing wheels\n      const used = new Set();\n      vis.textContent = '';\n      order.forEach((o) => {\n        if (o.type === 's') { const s = document.createElement('span'); s.className = 'vs-odometer-sep'; s.textContent = o.ch; vis.appendChild(s); return; }\n        let c = cols.get(o.place);\n        if (!c) { c = makeCol(o.place); cols.set(o.place, c); c.w = reduced ? 1 : 0; }\n        c.wGoal = 1; used.add(o.place); vis.appendChild(c.col);\n      });\n      // wheels no longer needed collapse, then leave\n      cols.forEach((c, pl) => { if (!used.has(pl)) { c.wGoal = 0; c.leaving = true; vis.insertBefore(c.col, vis.firstChild); } else c.leaving = false; });\n    }\n\n    function set(next, instant) {\n      readCfg();\n      const from = value; value = next;\n      el.setAttribute('aria-label', fmt.format(next));\n      layout(fmt.format(Math.abs(next) < 1e-9 ? 0 : next));\n      const scale = Math.pow(10, dec);\n      const a = Math.round(from * scale), b = Math.round(next * scale);\n      const jump = Math.abs(b - a);\n      const now = performance.now();\n      cols.forEach((c) => {\n        const k = c.place + dec;                         // wheel index in scaled integer units\n        const unit = Math.pow(10, k);\n        const dA = Math.floor(Math.abs(a) / unit) % 10, dB = Math.floor(Math.abs(b) / unit) % 10;\n        const dir = b >= a ? 1 : -1;\n        let steps = ((dB - dA) * dir + 10) % 10;         // shortest roll in the direction of change\n        // big jumps spin the lower wheels extra turns, fewer for higher places\n        const mag = jump > 0 ? Math.floor(Math.log10(jump)) : 0;\n        if (mag > k) steps += 10 * Math.min(2, mag - k);\n        if (instant || reduced) { c.p = dB; c.goal = dB; c.v = 0; paint(c); return; }\n        c.goal = c.goal + steps * dir;\n        // right to left: carries ripple like gears\n        const rank = k;\n        c.due = now + Math.max(0, rank) * cfg.stagger;\n      });\n      kick();\n    }\n\n    const paint = (c) => {\n      const m = ((c.p % 10) + 10) % 10;\n      c.strip.style.transform = `translate3d(0, ${(-m * 1.24).toFixed(4)}em, 0)`;\n    };\n    const paintW = (c) => {\n      // width in ch-like units: the column's natural width × w\n      if (!c.natural) c.natural = c.strip.firstChild.getBoundingClientRect().width || 1;\n      c.col.style.width = c.w >= .999 ? '' : (Math.max(0, c.w) * c.natural).toFixed(2) + 'px';\n      c.col.style.opacity = Math.max(0, Math.min(1, c.w)).toFixed(3);\n    };\n\n    function frame(now) {\n      const dt = Math.min(64, now - (last || now)) / 1000; last = now;\n      let busy = false;\n      cols.forEach((c, pl) => {\n        if (now >= c.due) {\n          for (let h = 0; h < dt; h += 1 / 120) {\n            const s = Math.min(1 / 120, dt - h);\n            c.v += (-cfg.k * (c.p - c.goal) - cfg.c * c.v) * s; c.p += c.v * s;\n            c.wv += (-220 * (c.w - c.wGoal) - 26 * c.wv) * s; c.w += c.wv * s;\n          }\n        } else busy = true;\n        const spinning = Math.abs(c.v) > 1.2;\n        c.col.classList.toggle('is-spinning', spinning);\n        // motion trail: a vertical-only blur (SVG filter) that deepens with wheel speed, digits/second\n        const lvl = cfg.trail <= 0 ? 0 : Math.min(3, Math.floor(Math.abs(c.v) * cfg.trail / 9));\n        if (lvl !== c.lvl) { c.lvl = lvl; c.strip.style.filter = lvl ? `url(#vs-odometer-mb${lvl})` : ''; }\n        if (Math.abs(c.v) < .002 && Math.abs(c.p - c.goal) < .001) { c.p = c.goal; c.v = 0; } else busy = true;\n        if (Math.abs(c.wv) < .002 && Math.abs(c.w - c.wGoal) < .001) { c.w = c.wGoal; c.wv = 0; } else busy = true;\n        paint(c); paintW(c);\n        if (c.leaving && c.w <= 0.001) { c.col.remove(); cols.delete(pl); }\n      });\n      raf = busy ? requestAnimationFrame(frame) : 0;\n    }\n    function kick() { if (!raf) { last = 0; raf = requestAnimationFrame(frame); } }\n\n    const api = { set: (n) => set(+n), get value() { return value; } };\n    el.vsOdometer = api;\n\n    // first view: roll up from data-vs-odometer-from\n    const fromAttr = el.dataset.vsOdometerFrom;\n    if (fromAttr === 'none' || reduced) { set(target, true); cols.forEach((c) => { c.w = 1; paintW(c); }); }\n    else {\n      set(parseFloat(fromAttr || '0'), true); cols.forEach((c) => { c.w = 1; paintW(c); });\n      el.setAttribute('aria-label', fmt.format(target));\n      const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { io.disconnect(); setTimeout(() => set(target), 150); } }, { threshold: .4 });\n      io.observe(el);\n    }\n    return api;\n  }\n\n  const ready = Promise.race([document.fonts ? document.fonts.ready : Promise.resolve(), new Promise((r) => setTimeout(r, 2000))]);\n  window.VsOdometer = { mount };\n  const init = () => ready.then(() => document.querySelectorAll('[data-vs-odometer]').forEach(mount));\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();\n})();\n</script>\n\n<!-- usage -->\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link href=\"https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@600&display=swap\" rel=\"stylesheet\">\n<div style=\"background:#eeeeea;color:#121417;padding:12vh 7vw;font:600 clamp(4rem,14vw,11rem)/1 'Chakra Petch',sans-serif;letter-spacing:-.04em\">\n  <span class=\"vs-odometer\" data-vs-odometer=\"48210\" style=\"--vs-odometer-active:#1f63ff\">48,210</span>\n  <button type=\"button\" onclick=\"this.previousElementSibling.vsOdometer.set(this.previousElementSibling.vsOdometer.value + Math.round(Math.random() * 900 + 7))\"\n          style=\"display:block;margin-top:24px;font:500 14px/1 system-ui;padding:12px 18px;border-radius:999px;border:0;background:#121417;color:#eeeeea;cursor:pointer\">Add some</button>\n</div>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Text & type",
    "tags": [
      "counter",
      "number",
      "odometer",
      "stats",
      "spring",
      "ticker",
      "dashboard"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [
      "https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@600&display=swap"
    ],
    "source": null,
    "free": true,
    "accent": "#1f63ff",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/odometer/index.html",
    "thumbnail": "https://vividsites.app/api/component/odometer/thumb.jpg",
    "video": "https://vividsites.app/api/component/odometer/thumb.mp4",
    "page": "https://vividsites.app/c/odometer"
  }
}