← Numbers

Live Tally

A number that keeps ticking up while you watch, at a steady average rate with natural, uneven gaps, for downloads, sign-ups or anything else. Each changed digit rolls up into place, a green dot pulses beside the label, and a little “+1” can float up with every increase.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-live-tally="48213" (the starting number) to an empty element. It takes the element’s font size and colour.

Options

Add any of these attributes to the same element.

data-rateAverage increases per second. Default 1.2.
data-stepLargest single increase. Default 1.
data-sinceA date such as 2026-01-01: the number grows by the rate from then, so every visitor sees the same count.
data-labelSmall text under the number, with a pulsing “live” dot.
data-popA little “+1” floats up with each increase.
data-count-inCount up from zero when it first comes into view.
data-prefix / data-suffixText either side of the number, such as $ or +.
data-localeHow the number is formatted. Default en-GB (1,284,330).

The rate is only for show; to show real numbers, set the starting value from your server when the page loads. Off screen it stops ticking and catches up when it comes back.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Live Tally: a number that keeps ticking up in real time, at a steady
    // average rate with natural, uneven gaps, for downloads, sign-ups or
    // anything else. Each changed digit rolls up into place.
    // Usage: <span data-live-tally="48213"></span>   (the starting number)
    // Optional: data-rate (average increases per second, default 1.2),
    // data-step (largest single increase, default 1), data-since (a date such as
    // "2026-01-01": the number grows by the rate from then, so every visitor
    // sees the same count), data-label (small text under the number, with a
    // pulsing "live" dot), data-pop (a little "+1" floats up on each increase),
    // data-count-in (count up from zero when it first comes into view),
    // data-prefix / data-suffix (text either side, e.g. "$" or "+"),
    // data-locale (number format, default "en-GB").
    var DEFAULTS = { rate: 1.2, step: 1, locale: 'en-GB' };

    var css =
      '.lvt{display:inline-flex;flex-direction:column;align-items:center;gap:.25em;font-variant-numeric:tabular-nums;line-height:1}' +
      '.lvt-num{position:relative;display:inline-flex;font-weight:800;letter-spacing:-.03em;white-space:nowrap}' +
      '.lvt-d{position:relative;display:inline-block;overflow:hidden;padding:.06em 0;margin:-.06em 0}' +
      '.lvt-d>span{display:inline-block}' +
      '.lvt-d>.lvt-out{position:absolute;left:0;top:.06em;animation:lvt-out .45s cubic-bezier(.5,0,.75,0) forwards}' +
      '.lvt-d>.lvt-in{animation:lvt-in .5s cubic-bezier(.2,.9,.25,1.15)}' +
      '@keyframes lvt-in{from{transform:translateY(105%)}}' +
      '@keyframes lvt-out{to{transform:translateY(-105%);opacity:.2}}' +
      '.lvt-label{display:inline-flex;align-items:center;gap:.45em;font-size:.32em;font-weight:700;letter-spacing:.12em;text-transform:uppercase;opacity:.75;white-space:nowrap}' +
      '.lvt-dot{position:relative;width:.7em;height:.7em;border-radius:50%;background:#22c55e;flex:none}' +
      '.lvt-dot::after{content:"";position:absolute;inset:0;border-radius:50%;background:inherit;animation:lvt-ping 1.6s cubic-bezier(0,0,.2,1) infinite}' +
      '@keyframes lvt-ping{75%,100%{transform:scale(2.6);opacity:0}}' +
      '.lvt-pop{position:absolute;right:-.6em;top:-.5em;font-size:.34em;font-weight:800;letter-spacing:0;color:#16a34a;pointer-events:none}' +
      '@media (prefers-reduced-motion: reduce){.lvt-d>span{animation:none!important}.lvt-dot::after{animation:none}}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function setup(el) {
      var d = el.dataset, start = parseFloat(el.getAttribute('data-live-tally')) || 0;
      var T = {
        el: el, rate: d.rate !== undefined ? +d.rate : DEFAULTS.rate, step: Math.max(1, +d.step || DEFAULTS.step),
        prefix: d.prefix || '', suffix: d.suffix || '', pop: d.pop !== undefined, countIn: d.countIn !== undefined,
        fmt: new Intl.NumberFormat(d.locale || DEFAULTS.locale), still: reduced(), visible: false, timer: 0, chars: []
      };
      if (d.since) {
        var since = Date.parse(d.since);
        if (!isNaN(since)) start += Math.max(0, Math.floor((Date.now() - since) / 1000 * T.rate));
      }
      T.value = start;
      T.clock = Date.now();
      el.classList.add('lvt');
      el.innerHTML = '<span class="lvt-num" aria-hidden="true"></span>' +
        (d.label ? '<span class="lvt-label" aria-hidden="true"><i class="lvt-dot"></i>' + d.label + '</span>' : '');
      T.num = el.querySelector('.lvt-num');
      el.setAttribute('role', 'status');
      el.setAttribute('aria-live', 'off');
      render(T, T.countIn && !T.still ? 0 : T.value, false);
      return T;
    }

    function text(T, v) { return T.prefix + T.fmt.format(Math.round(v)) + T.suffix; }

    // Writes the number, rolling up only the characters that changed.
    function render(T, v, roll) {
      var s = text(T, v), chars = s.split(''), slots = T.num.children, i;
      T.el.setAttribute('aria-label', s + (T.el.dataset.label ? ' ' + T.el.dataset.label : ''));
      // Grow or shrink from the left so the units line up.
      while (T.chars.length < chars.length) {
        var slot = document.createElement('span');
        slot.className = 'lvt-d';
        slot.innerHTML = '<span></span>';
        T.num.insertBefore(slot, T.num.firstChild);
        T.chars.unshift('');
      }
      while (T.chars.length > chars.length) { T.num.removeChild(T.num.querySelector('.lvt-d')); T.chars.shift(); }
      var digits = T.num.querySelectorAll('.lvt-d');
      for (i = 0; i < chars.length; i++) {
        if (T.chars[i] === chars[i]) continue;
        var box = digits[i], cur = box.lastChild;
        if (roll && T.chars[i] !== '') {
          [].forEach.call(box.querySelectorAll('.lvt-out'), function (o) { o.remove(); });
          cur.className = 'lvt-out';
          var nxt = document.createElement('span');
          nxt.className = 'lvt-in';
          nxt.textContent = chars[i];
          box.appendChild(nxt);
          setTimeout(function (o) { o.remove(); }.bind(null, cur), 460);
        } else cur.textContent = chars[i];
        T.chars[i] = chars[i];
      }
    }

    function popUp(T, n) {
      var p = document.createElement('span');
      p.className = 'lvt-pop';
      p.textContent = '+' + T.fmt.format(n);
      p.style.opacity = 0;
      T.num.appendChild(p);
      anime.animate(p, { y: ['.4em', '-.9em'], opacity: [{ to: 1, duration: 150 }, { to: 0, duration: 650, delay: 200 }], duration: 1000, ease: 'out(2)', onComplete: function () { p.remove(); } });
    }

    // Waits a random, Poisson-like gap, then adds a small amount.
    function schedule(T) {
      clearTimeout(T.timer);
      if (!T.visible || T.rate <= 0) return;
      var gap = -Math.log(1 - Math.random()) * ((T.step + 1) / 2) / T.rate * 1000, add = 1 + Math.floor(Math.random() * T.step);
      if (gap < 140) { add = Math.max(1, Math.round(T.rate * 0.14 * (0.5 + Math.random()))); gap = 140; }
      T.timer = setTimeout(function () {
        T.value += add;
        T.clock = Date.now();
        render(T, T.value, !T.still);
        if (T.pop && !T.still) popUp(T, add);
        schedule(T);
      }, Math.min(gap, 6000));
    }

    function show(T) {
      T.visible = true;
      if (!T.counted && T.countIn && !T.still) {
        T.counted = true;
        var st = { v: 0 };
        anime.animate(st, {
          v: T.value, duration: 1600, ease: 'out(4)',
          onUpdate: function () { render(T, st.v, false); },
          onComplete: function () { render(T, T.value, false); schedule(T); }
        });
        return;
      }
      T.counted = true;
      // Catch up on the time spent off screen.
      var missed = Math.floor((Date.now() - T.clock) / 1000 * T.rate);
      if (missed > 0) { T.value += missed; T.clock = Date.now(); render(T, T.value, !T.still); }
      schedule(T);
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var T = e.target._lvt;
          if (e.isIntersecting) show(T);
          else { T.visible = false; clearTimeout(T.timer); }
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-live-tally]').forEach(function (el) {
        if (el._lvt) return;
        el._lvt = setup(el);
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<span data-live-tally="48213" data-label="Sign-ups today" data-pop style="font-size: 48px"></span>

<!-- the same count for every visitor, about 3 a minute since January: -->
<span data-live-tally="120000" data-since="2026-01-01" data-rate="0.05"></span>