Count-up numbers
Stats count from zero to their value when scrolled into view, with an ease-out so they settle gently.
JavaScriptBeginner
<p class="hint">Scroll down to the numbers</p>
<div class="stats">
<div><div class="num" data-to="1250">1,250</div><div class="label">Members</div></div>
<div><div class="num" data-to="98" data-suffix="%">98%</div><div class="label">Satisfaction</div></div>
<div><div class="num" data-to="42">42</div><div class="label">Countries</div></div>
</div>
body{display:block;height:auto;padding-top:300px;padding-bottom:200px}
.hint{position:absolute;top:60px;left:0;right:0;text-align:center;color:#a6938f;font-size:14px}
.stats{display:flex;gap:clamp(20px,6vw,56px);justify-content:center;flex-wrap:wrap;text-align:center}
.num{font-size:clamp(2.2rem,8vw,3.6rem);font-weight:800;letter-spacing:-.03em;color:#f08a5d;font-variant-numeric:tabular-nums}
.label{color:#cfc3c1;font-size:14px}
const easeOut = t => 1 - Math.pow(1 - t, 3);
function count(el) {
const to = +el.dataset.to, suffix = el.dataset.suffix || '', dur = 1800;
const start = performance.now();
(function tick(now) {
const t = Math.min((now - start) / dur, 1);
el.textContent = Math.round(to * easeOut(t)).toLocaleString() + suffix;
if (t < 1) requestAnimationFrame(tick);
})(start);
}
const io = new IntersectionObserver(entries => {
entries.forEach(e => {
if (!e.isIntersecting) return;
count(e.target);
io.unobserve(e.target);
});
}, { threshold: 0.6 });
document.querySelectorAll('.num').forEach(n => io.observe(n));
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Count up</title>
<style>
*{box-sizing:border-box}
html,body{margin:0;height:100%}
body{display:grid;place-items:center;background:#140807;color:#fffffc;font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}
body{display:block;place-items:normal;height:auto;padding-top:300px;padding-bottom:200px}
.hint{position:absolute;top:60px;left:0;right:0;text-align:center;color:#a6938f;font-size:14px}
.stats{display:flex;gap:clamp(20px,6vw,56px);justify-content:center;flex-wrap:wrap;text-align:center}
.num{font-size:clamp(2.2rem,8vw,3.6rem);font-weight:800;letter-spacing:-.03em;color:#f08a5d;font-variant-numeric:tabular-nums}
.label{color:#cfc3c1;font-size:14px}
</style></head>
<body>
<p class="hint">Scroll down to the numbers</p>
<div class="stats">
<div><div class="num" data-to="1250">1,250</div><div class="label">Members</div></div>
<div><div class="num" data-to="98" data-suffix="%">98%</div><div class="label">Satisfaction</div></div>
<div><div class="num" data-to="42">42</div><div class="label">Countries</div></div>
</div>
<script>
const easeOut = t => 1 - Math.pow(1 - t, 3);
function count(el) {
const to = +el.dataset.to, suffix = el.dataset.suffix || '', dur = 1800;
const start = performance.now();
(function tick(now) {
const t = Math.min((now - start) / dur, 1);
el.textContent = Math.round(to * easeOut(t)).toLocaleString() + suffix;
if (t < 1) requestAnimationFrame(tick);
})(start);
}
const io = new IntersectionObserver(entries => {
entries.forEach(e => {
if (!e.isIntersecting) return;
count(e.target);
io.unobserve(e.target);
});
}, { threshold: 0.6 });
document.querySelectorAll('.num').forEach(n => io.observe(n));
</script>
</body></html>
Good for
Statistic strips, results sections and dashboards. Use real numbers only.
Watch out for
Format with toLocaleString and keep the final value in the HTML so it reads correctly without JS.
Accessibility
Wrap motion in @media (prefers-reduced-motion: reduce) and switch it off or shorten it for people who ask for less movement.