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
- Paste the snippet just before
</head>. - 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-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 with each increase. |
data-count-in | Count up from zero when it first comes into view. |
data-prefix / data-suffix | Text either side of the number, such as $ or +. |
data-locale | How 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.
<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><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>