07 / blog / how-toNovember 20268 min read

How to animate a number counter on scroll (CSS and JavaScript)

A number that counts up as it comes into view is a value that changes over time, a format for each step, and something that starts it when the reader gets there. CSS can now do the counting on its own; JavaScript does it anywhere, with separators and decimals. Here are both, the observer that starts them, the CSS that stops the width jumping, and what a screen reader should hear.

  • How-To
  • counter
  • IntersectionObserver
  • Intl.NumberFormat
Pure CSS
@property · integer
JavaScript
requestAnimationFrame
scrollCounter
in the 11.9 KB main entry
Time0.5 s1 s1.5 s2 s
easeOut5,4259,30011,62512,400
linear3,1006,2009,30012,400
Fig. 1 — the count-up below, counting to 12,400 over 2 s. With easeOut, t × (2 − t), the number is at 9,300 after one second, three quarters of the way, and spends the second second on the last quarter. Linear is the dashed line.
01Pure CSS

A number counter in CSS, with @property

CSS cannot animate the text of an element, but it can print a counter, and since @property it can animate a number. Register a custom property as an <integer>, and the browser interpolates it between keyframes like any length or colour, in whole numbers (with one exception, below). Feed it to counter-reset and print the counter in ::after, and the number on screen counts.

index.html6 lines
<p class="stat">
  <span class="visually-hidden">12,400 users</span>
  <span aria-hidden="true">
    <span class="css-count" style="--to: 12400"></span> users
  </span>
</p>
counter.css31 lines
/* Register --n as a whole number, so the browser can animate it. */
@property --n {
  syntax: '<integer>';
  inherits: false;
  initial-value: 0;
}

.css-count {
  --n: var(--to);                    /* at rest: the final number */
  counter-reset: n calc(var(--n)) final var(--to);
  display: inline-grid;
  font-variant-numeric: tabular-nums;
}
/* Two layers in one cell: the final number, invisible, holds the width. */
.css-count::before,
.css-count::after {
  grid-area: 1 / 1;
  text-align: end;
}
.css-count::before { content: counter(final); visibility: hidden; }
.css-count::after  { content: counter(n); }

@media (prefers-reduced-motion: no-preference) {
  .css-count.is-waiting { --n: 0; }
  .css-count.is-in { animation: count-up 2s ease-out; }
}

/* No "to" keyframe: the count ends on the element's own --n. */
@keyframes count-up {
  from { --n: 0; }
}

At rest --n is the final number, so a page without the script, and a reader who asks for reduced motion, sees 12400 straight away. A small observer holds it at 0 with one class, and swaps it for the class that runs the animation once the number is a fifth of the way up the window:

counter.js14 lines
// Hold each counter at 0 until it is 20% above the bottom of the
// window, then let the CSS count. Once only.
const cssObserver = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    if (!entry.isIntersecting) continue;
    entry.target.classList.replace('is-waiting', 'is-in');
    cssObserver.unobserve(entry.target);
  }
}, { rootMargin: '0px 0px -20% 0px' });

for (const el of document.querySelectorAll('.css-count')) {
  el.classList.add('is-waiting');
  cssObserver.observe(el);
}

Two details in the CSS are there for one engine each. WebKit, Safari’s engine, animates an <integer> property through fractions, 3096.9 and so on, and counter-reset rejects a fraction, so without the calc(), which rounds, Safari shows 0 for most of the count. And Firefox does not count towards a var() in a keyframe, it jumps, so the keyframes have no to: an animation without one ends on the element’s own value, which is --to.

The two pseudo-elements keep the line still. Both sit in the same grid cell: ::before prints the final number, invisibly, and so sets the width, and ::after counts inside it, aligned to the right. With tabular-nums each digit keeps one width as it changes. The visually hidden copy and the aria-hidden are for screen readers, and come up again below.

02Pure CSS, no script

Let the scroll run the CSS counter

A scroll timeline can stand in for the observer. The count then follows the scroll instead of a clock: it runs as the number moves up the screen, and counts back down if the reader scrolls back.

counter-scrub.css12 lines
/* Instead of the observer, no script at all: the count follows the scroll. */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .css-count {
      animation: count-up ease-out both;
      animation-timeline: view();
      /* 0 when the whole number is on screen at the bottom,
         the final number when it reaches the middle */
      animation-range: entry 100% cover 50%;
    }
  }
}

Use this or the observer, not both. view() works in Chrome and Edge 115+, Safari 26+ and Firefox 159+; anywhere else the @supports block is skipped and the final number shows. How entry and cover ranges work is in CSS scroll-driven animations explained.

03JavaScript

A count-up in JavaScript with requestAnimationFrame

The JavaScript version writes the number into the element itself, so it can format each step. Start with the real numbers in the HTML: search engines and anyone without JavaScript see them, and the script measures them before it counts.

index.html20 lines
<ul class="stats">
  <li>
    <span class="visually-hidden">12,400+ users</span>
    <span aria-hidden="true">
      <span class="count" id="users" data-to="12400" data-suffix="+">12,400+</span> users
    </span>
  </li>
  <li>
    <span class="visually-hidden">$86,500 raised</span>
    <span aria-hidden="true">
      <span class="count" data-to="86500" data-prefix="$">$86,500</span> raised
    </span>
  </li>
  <li>
    <span class="visually-hidden">Rated 4.8 out of 5</span>
    <span aria-hidden="true">
      Rated <span class="count" data-to="4.8" data-decimals="1">4.8</span> / 5
    </span>
  </li>
</ul>

The count is one function. On each frame it works out how far through the duration it is, t from 0 to 1, bends that with an easing, and writes from + (to − from) × eased t. The last frame has t at exactly 1, so the count ends on to itself, never on 12,399.

count.js18 lines
// Fast at first, slow at the end: t × (2 − t), the curve
// svg-scroll-draw calls ease-out. Fig. 1 plots it against linear.
const easeOut = (t) => t * (2 - t);

function countUp(el, { from = 0, to, duration = 2000, format = Math.round }) {
  if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
    el.textContent = format(to); // no count, just the final number
    return;
  }
  let start;
  function frame(now) {
    start ??= now; // the first frame's own clock, so t never goes below 0
    const t = Math.min(1, (now - start) / duration);
    el.textContent = format(from + (to - from) * easeOut(t));
    if (t < 1) requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);
}

Linear counting looks mechanical and seems to stop dead at the end; easing out slows the last few digits, which is how a counter settles (Fig. 1). Any function from 0 to 1 works: the easing visualizer plots the common ones, and copies them as code.

04Intl.NumberFormat

Separators, decimals, prefix and suffix

Math.round prints 12400, not 12,400. Intl.NumberFormat adds the separators the reader’s language uses, and fixes the decimals. This reads the decimals, a prefix and a suffix from the element’s data attributes, and an optional data-locale; without one it formats in the reader’s own language.

format.js10 lines
// One formatter per counter, made once: it runs on every frame.
function formatterFor(el) {
  const decimals = Number(el.dataset.decimals ?? 0);
  const number = new Intl.NumberFormat(el.dataset.locale, {
    minimumFractionDigits: decimals,
    maximumFractionDigits: decimals,
  });
  const { prefix = '', suffix = '' } = el.dataset;
  return (n) => prefix + number.format(n) + suffix;
}

Set both fraction digits. Left to its defaults, the formatter shows up to three decimals, and 600 ms into a count to 4.8 the number reads 2.448 instead of 2.4. The same formatter covers currency, percent and short forms:

intl.js6 lines
new Intl.NumberFormat('en-US').format(1234567.5);  // "1,234,567.5"
new Intl.NumberFormat('de-DE').format(1234567.5);  // "1.234.567,5"
new Intl.NumberFormat('en-IN').format(1234567.5);  // "12,34,567.5"
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' }).format(86500);  // "€86,500.00"
new Intl.NumberFormat('en-US', { style: 'percent' }).format(0.42);  // "42%"
new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(2480000);  // "2.5M"
05IntersectionObserver

Start it when it scrolls into view

A counter below the fold that runs when the page loads has finished before anyone sees it. An IntersectionObserver starts each one when it is a fifth of the way up the window, then stops watching it, so it counts once.

observe.js20 lines
const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    // Still below the line: wait. Above the window (the page opened
    // scrolled past it): count anyway, so it does not sit at 0.
    if (!entry.isIntersecting && entry.boundingClientRect.top > 0) continue;
    const el = entry.target;
    observer.unobserve(el); // count once
    countUp(el, { to: Number(el.dataset.to), format: formatterFor(el) });
  }
}, { rootMargin: '0px 0px -20% 0px' }); // once it is 20% above the bottom

// Under reduced motion, leave the number the HTML has.
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) {
  for (const el of document.querySelectorAll('.count')) {
    // Hold the width of the final number, which is in the HTML, then go to 0.
    el.style.minWidth = el.getBoundingClientRect().width + 'px';
    el.textContent = formatterFor(el)(0);
    observer.observe(el);
  }
}

Two cases are easy to miss. A page opened half way down, after a reload or from a link, has counters above the window that never cross into it, so the observer counts those at once rather than leaving them on 0. And everything that changes the number is behind the reduced-motion check, so for those readers the HTML’s own number stays put. To start the count as the number appears rather than a little later, drop the rootMargin.

06tabular-nums

Stop the width from jittering

In most fonts a 1 is narrower than an 8, so as the digits change the number gets wider and narrower many times a second, and the word after it shakes. font-variant-numeric: tabular-nums gives every digit the same width. That still leaves the number growing from 0 to 12,400+, which is what the min-width line in the observer is for: it takes the width of the final number before the count replaces it.

count.css5 lines
.count {
  display: inline-block;               /* so min-width applies */
  text-align: end;                     /* digits grow to the left */
  font-variant-numeric: tabular-nums;  /* a 1 as wide as an 8 */
}

With both, the box is the same width on every frame, and text-align: end grows the digits from the right, the way a meter fills. If the counter uses a web font, the width is taken in whatever font has loaded at that moment, so let the font load before the script runs.

07Accessibility

Screen readers and reduced motion

A counting element changes its text on every frame, sixty times a second on most screens. A screen reader that reaches it mid-count reads whichever number is showing, 3,871 users, and the HTML in both versions above avoids that: the counting part is aria-hidden, and a visually hidden copy of the whole sentence, with the final number, is what gets read, once.

visually-hidden.css8 lines
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
  • Do not put aria-live on a counter. It asks the screen reader to announce every change, and a count is a stream of them. The final number is not news; it only needs to be there when the reader arrives.
  • An aria-label on the span is not a substitute. A plain span has no role, ARIA does not allow a name on it, and screen readers are free to ignore one.
  • Reduced motion means no count, not a slower one. Every version here shows the final number at once: the CSS keeps its animation inside prefers-reduced-motion: no-preference, and the script checks matchMedia before it touches the number. More on what that setting asks for is in reduced motion for scroll animations.
08svg-scroll-draw

The same with a library

svg-scroll-draw’s scrollCounter is the count, the start and the reduced-motion check in one call. With the HTML above:

users.js10 lines
import { scrollCounter } from 'svg-scroll-draw';

const whole = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 });

// 0 when the number's top is 80% of the way down the window,
// 12,400+ when it reaches 20%: the count follows the scroll.
scrollCounter('#users', {
  to: 12400,
  format: (n) => whole.format(n) + '+',
});

It is scrubbed rather than played: the number is a function of the scroll position, not of time, so it counts as fast as the reader scrolls. By default it is at from (0) when the element’s top passes 80% of the way down the window and reaches to at 20%, with the same ease-out as Fig. 1. once is on, so scrolling back up leaves it where it got to; with once: false it counts back down. trigger moves the window, easing takes a name or any function, and onComplete runs when it reaches the end.

Left without format, it prints the rounded number with no separators. Its decimals option is toFixed, also without separators, and it replaces format entirely, so with a formatter, set the decimals in the formatter. Under reduced motion it writes the final number at once, also when the setting is turned on while the page is open. It adds no ARIA, so keep the hidden copy. The animated counter generator sets all of this up with a preview and copies the code, plain JavaScript or a React component, and the scroll counter example is a whole stats row, running.

— / next

Set the numbers, copy the counter

Pick where it starts and ends, the decimals, a prefix or suffix and the easing, watch it count, and copy the JavaScript or a React component.

Star on GitHubFound this useful? A star helps others find it.

→Parts list

Keep reading

More from the blog