Skip to content
WANCODE
BM

Animated Counter

Count numbers up smoothly with requestAnimationFrame and easing, start on scroll, and respect prefers-reduced-motion.

Live preview Open in Playground
counter.js
/**
 * 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.NumberFormat adds 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.
  • progress is elapsed time ÷ duration, capped at 1 with Math.min. Using real time (not a frame counter) keeps the duration correct on 60 Hz and 120 Hz screens alike.
  • easeOutCubic starts fast and slows down near the end, which feels natural. Swap it for (t) => t to see linear motion.
  • IntersectionObserver tells us when the element is at least half visible, so the animation plays when someone can actually see it — then we unobserve so 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

HTML
<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>