← Numbers

Odometer

Every digit spins on its own wheel and lands on its value, like a car’s mileage counter. Commas, currency signs and units stay put.

$12,480

How to use it

  1. Paste the snippet just before </head>.
  2. Put the number in an element with data-odometer. It rolls when it scrolls into view.

Options

Add any of these attributes to the same element.

data-values="A|B|C"Several values to roll through in turn.
data-durationHow long the wheels spin. Default 1800 ms.
data-staggerDelay between neighbouring digits. Default 90 ms.
data-holdHow long each value stays, with data-values. Default 1800 ms.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Odometer: every digit of a number spins on its own wheel and lands on its
    // value, like a car's mileage counter. Commas, symbols and units stay still.
    // Usage: <span data-odometer>12,480</span>
    // Optional: data-values="1,250|8,420|23,914" (rolls through them in turn),
    // data-duration (ms), data-stagger (ms between digits), data-hold (ms per value).
    var DEFAULTS = { duration: 1800, stagger: 90, hold: 1800 };

    var css =
      '.od{display:inline-flex;font-variant-numeric:tabular-nums}' +
      '.od-col{display:inline-grid;position:relative;clip-path:inset(0 -0.1em)}' +
      '.od-size{visibility:hidden}' +
      '.od-strip{position:absolute;left:0;right:0;top:0;display:flex;flex-direction:column;text-align:center;will-change:transform}' +
      '.od-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function setup(el) {
      var list = el.dataset.values ? el.dataset.values.split('|') : [el.textContent];
      var values = list.map(function (v) { return v.trim(); }).filter(Boolean);
      el.classList.add('od');
      el.textContent = '';
      var sr = document.createElement('span');
      sr.className = 'od-sr';
      el.appendChild(sr);
      var box = document.createElement('span');
      box.setAttribute('aria-hidden', 'true');
      box.style.display = 'inline-flex';
      el.appendChild(box);
      return { el: el, values: values, sr: sr, box: box, digits: [] };
    }

    // Lay out the value: a spinning column per digit, plain text for the rest.
    function layout(o, value) {
      o.sr.textContent = value;
      o.box.textContent = '';
      o.digits = [];
      Array.from(value).forEach(function (ch) {
        if (!/[0-9]/.test(ch)) {
          var s = document.createElement('span');
          s.textContent = ch;
          o.box.appendChild(s);
          return;
        }
        var col = document.createElement('span');
        col.className = 'od-col';
        var size = document.createElement('span');
        size.className = 'od-size';
        size.textContent = '0';
        var strip = document.createElement('span');
        strip.className = 'od-strip';
        for (var i = 0; i < 20; i++) {
          var n = document.createElement('span');
          n.textContent = i % 10;
          strip.appendChild(n);
        }
        col.appendChild(size);
        col.appendChild(strip);
        o.box.appendChild(col);
        o.digits.push({ col: col, strip: strip, target: +ch });
      });
    }

    function roll(o, value, onDone) {
      var d = o.el.dataset;
      var previous = o.digits.map(function (g) { return g.target; });
      layout(o, value);
      var h = o.digits.length ? o.digits[0].col.getBoundingClientRect().height : 0;
      var count = o.digits.length;
      o.digits.forEach(function (g, i) {
        g.strip.childNodes.forEach(function (n) { n.style.height = h + 'px'; n.style.lineHeight = h + 'px'; });
        // Start where this wheel was last time (aligned from the right), then spin
        // forward at least one full turn to the new digit.
        var from = previous[previous.length - (count - i)];
        from = from === undefined ? 0 : from;
        g.strip.style.transform = 'translateY(' + -from * h + 'px)';
        anime.animate(g.strip, {
          y: [-from * h, -(10 + g.target) * h],
          duration: (+d.duration || DEFAULTS.duration) + i * 120,
          delay: i * (+d.stagger || DEFAULTS.stagger),
          ease: 'out(4)',
          onComplete: i === count - 1 ? onDone : undefined
        });
      });
      if (!count && onDone) onDone();
    }

    function run(o) {
      var d = o.el.dataset;
      var i = 0;
      (function next() {
        roll(o, o.values[i], function () {
          if (o.values.length < 2) return;
          i = (i + 1) % o.values.length;
          setTimeout(next, +d.hold || DEFAULTS.hold);
        });
      })();
    }

    function init() {
      var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          run(e.target._od);
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-odometer]').forEach(function (el) {
        if (el._od) return;
        var o = (el._od = setup(el));
        if (reduced) { o.box.textContent = o.values[0]; o.sr.textContent = o.values[0]; return; }
        layout(o, o.values[0].replace(/[0-9]/g, '0'));
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<p>Trusted by <span data-odometer>12,480</span> teams</p>