← Visual Library / Effects

Counting numbers

Stats count up from 0 to their final value when they scroll into view. Sells credibility — adds energy to a static section.

Effects Built
Use when — you have impact stats ('15k clients', '$2.4M shipped', '99.9% uptime') and want them to feel earned, not stamped on. Best paired with reveal-on-scroll (tile #17).

Live demo

0Clients shipped
$0Revenue handled
0%Uptime

CSS

/* 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 */
}

JS — IntersectionObserver + animate

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));

Dials

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.