Growing bar chart
Bars of a chart grow to their values in sequence when the chart scrolls into view.
Numbers roll up from zero with an ease-out curve the moment they come into view.
When a figure becomes visible, a requestAnimationFrame loop eases a value from 0 to its data-to attribute over 1.6 seconds and formats it with toLocaleString. Leaving the viewport resets it so it counts again next time.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Count-up stats</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; }
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; -webkit-font-smoothing: antialiased; }
body { background: #fefae0; color: #283618; }
.pad { height: 70vh; display: grid; place-content: center; }
.pad h1 { font-size: 3rem; margin: 0; }
.stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1px; background: #dda15e; border-block: 1px solid #dda15e; }
.stats div { background: #fefae0; padding: 8vh 6vw; }
.stats b { display: block; font: 900 clamp(2.8rem, 9vw, 6rem)/1 system-ui, sans-serif; letter-spacing: -.03em; font-variant-numeric: tabular-nums; color: #bc6c25; }
.stats span { font-size: 1.1rem; }
</style>
</head>
<body>
<div class="pad"><h1>Count-up stats</h1></div>
<section class="stats">
<div><b data-to="12480">0</b><span>pages scrolled today</span></div>
<div><b data-to="98">0</b><span>percent of reading on phones</span></div>
<div><b data-to="3600">0</b><span>pixels per long article</span></div>
<div><b data-to="42">0</b><span>flicks per minute</span></div>
</section>
<div class="pad"></div>
<script>
const ease = t => 1 - Math.pow(1 - t, 3);
function run(el) {
const to = +el.dataset.to, start = performance.now();
cancelAnimationFrame(el._raf);
const tick = now => {
const t = Math.min(1, (now - start) / 1600);
el.textContent = Math.round(ease(t) * to).toLocaleString('en-US');
if (t < 1) el._raf = requestAnimationFrame(tick);
};
el._raf = requestAnimationFrame(tick);
}
const io = new IntersectionObserver(entries => {
entries.forEach(e => {
if (e.isIntersecting) run(e.target);
else { cancelAnimationFrame(e.target._raf); e.target.textContent = '0'; }
});
}, { threshold: 0.6 });
document.querySelectorAll('[data-to]').forEach(el => io.observe(el));
</script>
</body>
</html>