Stats count up from 0 to their final value when they scroll into view. Sells credibility — adds energy to a static section.
/* Just basic styling — animation is in JS via IntersectionObserver */
.stat .v {
font-size: 2.8rem;
font-weight: 800;
color: var(--accent);
font-variant-numeric: tabular-nums; /* prevents width jitter */
}
function animateCount(el) {
const target = parseFloat(el.dataset.target);
const suffix = el.dataset.suffix || '';
const prefix = el.dataset.prefix || '';
const fmt = el.dataset.format;
const duration = 1600;
const start = performance.now();
function step(t) {
const p = Math.min(1, (t - start) / duration);
const eased = 1 - Math.pow(1 - p, 3); /* ease-out cubic */
let v = target * eased;
if (fmt === 'm') v = '$' + (v / 1000000).toFixed(2) + 'M';
else if (Number.isInteger(target)) v = prefix + Math.round(v).toLocaleString() + suffix;
else v = prefix + v.toFixed(1) + suffix;
el.textContent = v;
if (p < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
const obs = new IntersectionObserver((entries) => {
entries.forEach(e => {
if (e.isIntersecting) { animateCount(e.target); obs.unobserve(e.target); }
});
}, { threshold: 0.4 });
document.querySelectorAll('.stat .v[data-target]').forEach(el => obs.observe(el));
Duration: 1.6s feels right. Faster = panic; slower = boring.
Easing: ease-out cubic (fast start, slow finish). Hits the final number with weight.
Tabular nums: font-variant-numeric: tabular-nums stops the number jiggling sideways as digits change width.
Source: Jono Catliff masterclass, 2026-05-19. See masterclass notes.