Animated Counter
Count numbers up smoothly with requestAnimationFrame and easing, start on scroll, and respect prefers-reduced-motion.
Live preview Open in Playground
/**
* Counts an element up from 0 to its data-target value.
* Respects prefers-reduced-motion by jumping straight to the end.
*/
export function animateCounter(el, { duration = 1500 } = {}) {
const target = Number(el.dataset.target);
const format = new Intl.NumberFormat(document.documentElement.lang || undefined);
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduceMotion || duration <= 0) {
el.textContent = format.format(target);
return;
}
const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3);
const start = performance.now();
function tick(now) {
const progress = Math.min((now - start) / duration, 1);
el.textContent = format.format(Math.round(target * easeOutCubic(progress)));
if (progress < 1) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}
/** Start each counter the first time it scrolls into view. */
export function animateCountersOnScroll(selector = "[data-target]") {
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
animateCounter(entry.target);
observer.unobserve(entry.target); // only once
}
}, { threshold: 0.5 });
document.querySelectorAll(selector).forEach((el) => observer.observe(el));
}A counter animation is just this: on every frame, work out how far through the animation we are (0 → 1), and show that fraction of the target number. requestAnimationFrame calls us right before the browser paints, so the animation is smooth and pauses automatically in background tabs.
Line by line
Intl.NumberFormatadds thousands separators the way the page's language expects (12,500), instead of hand-written string tricks.matchMedia("(prefers-reduced-motion: reduce)")reads the user's operating-system setting. If they asked for less motion, we show the final number immediately — same information, no movement.progressis elapsed time ÷ duration, capped at 1 withMath.min. Using real time (not a frame counter) keeps the duration correct on 60 Hz and 120 Hz screens alike.easeOutCubicstarts fast and slows down near the end, which feels natural. Swap it for(t) => tto see linear motion.IntersectionObservertells us when the element is at least half visible, so the animation plays when someone can actually see it — then weunobserveso it runs only once.
Screen readers
A number that changes 60 times a second is noise for a screen reader. Put aria-hidden="true" on the animated number and add the final value in visually-hidden text next to it, as in the usage example.
Usage
<p class="stat">
<span data-target="12500" aria-hidden="true">0</span>
<span class="visually-hidden">12,500</span>
learners
</p>
<script type="module">
import { animateCountersOnScroll } from "./counter.js";
animateCountersOnScroll();
</script>