tool / animated counterjavascript · react

Animated counter generator

Set where the number starts and ends, its decimals, a prefix or suffix and the easing, and watch it count. Then copy the code: plain JavaScript or a React component that counts up as the reader scrolls.

Open the generator ↓
Options
8
Easings
7
Locales
5
Output
2
window9,300progressend · top 20%start · top 80%10.50ease-outlinear06,2009,30012,400value = from + (to − from) × ease(progress)
Fig. 1 — progress is 0 when the element’s top crosses 80% of the window and 1 at 20%. Half way, ease-out already shows 9,300 of 12,400; linear would show 6,200.
01The tool

Make your counter

decimals
separators
notation
once
screen-reader copy
Preview. Final value: 1,250,000
main.js12 lines
// <span class="sr-only">1,250,000</span>
// <span id="stat" aria-hidden="true">1,250,000</span>
// sr-only hides the copy from sight, not from screen readers (Tailwind has the class).

import { scrollCounter } from 'svg-scroll-draw';

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

scrollCounter('#stat', {
  to: 1250000,
  format: (n) => nf.format(n),
});

On real scroll · each one counts as it crosses its window, no seek()

Yours

1,250,000

the settings above

Users

12,400+

separators, a plus

Uptime

99.98%

two decimals, a percent

Revenue

$4.2M

compact, a currency sign

Reply time

180 ms

counting down

These follow the page. With once: true, scrolling back up leaves each number where it got to; an example’s Edit button loads its settings into the generator.

02The model

How the number follows the scroll

Behind the number is one value, its progress from 0 to 1: 0 when the element’s top crosses 80% of the window, 1 when it reaches 20% (fig. 1). The easing bends it, and the number shown is from + (to − from) × eased progress, written again on every frame while the element is on screen. Scroll faster and it counts faster. Scroll back up and it stays where it got to, because once is on by default; with once: false it counts back down.

main.js9 lines
import { scrollCounter } from 'svg-scroll-draw';

// 0 when the element's top is 80% down the window, 1 when it is at 20%.
const users = scrollCounter('#users', { to: 12400 });

// Driving it by hand, as the generator's Play button does:
users.seek(0.5);   // 6200: from + (to − from) × 0.5, no easing; stops following the scroll
users.resume();    // follows the scroll again
users.destroy();   // stops for good

The window is measured again when the page resizes or its layout moves. On each frame the counter also writes its eased progress to --scroll-draw-progress on the element, for anything else that should follow the count.

counter.css5 lines
/* The counter writes its eased progress on its own element:
   0 to 1, and past 1 with spring or elastic. */
#users {
  opacity: calc(0.25 + 0.75 * var(--scroll-draw-progress, 1));
}
Reduced motion
For readers who ask their system for less motion, the final number is written at once, with no count, and onComplete still runs; that holds when the setting is turned on while the page is open, too. On a page that loads that way the CSS variable is never written, so give var() a fallback of 1. Turned on mid-visit, the variable keeps the last value it had.
Already past
An element already above its window when the page loads shows its final number straight away, not from.
03Intl.NumberFormat

Formatting the number

Left alone, the counter rounds and prints the bare number: 12400, not 12,400. The format option takes the raw value on every frame and returns the text, and the browser’s own Intl.NumberFormat does the rest: separators, decimals, currency and compact notation, in the reader’s language or yours. Make the formatter once, outside the function, and set maximumFractionDigits, or half way through a count it shows up to three decimals.

format.js13 lines
import { scrollCounter } from 'svg-scroll-draw';

// Make each formatter once: format is called on every frame with the raw value.
const whole = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 });
scrollCounter('#users', { to: 12400, format: (n) => whole.format(n) + '+' });   // 12,400+

const money = new Intl.NumberFormat('en-US', {
  style: 'currency', currency: 'USD', notation: 'compact', maximumFractionDigits: 1,
});
scrollCounter('#revenue', { to: 4200000, format: (n) => money.format(n) });     // $4.2M

// decimals alone is toFixed: no separators, and it replaces format entirely.
scrollCounter('#uptime', { to: 99.98, decimals: 2 });                          // 99.98
Locale1234567.891, 2 decimals4200000, compact
en-US1,234,567.894.2M
en-IN12,34,567.8942L
de-DE1.234.567,894,2 Mio.
fr-FR1 234 567,894,2 M
ja-JP1,234,567.89420万
decimals
decimals is the counter’s shortcut for n.toFixed(decimals), and when it is set format is ignored. Use one or the other; the generator only writes decimals when nothing else about the number changes and the count stays on one side of zero.
Crossing zero
A count from below zero to above it passes values such as -0.3, which toFixed and Intl.NumberFormat both print as -0. The generator’s code formats those as 0.
04Accessibility

A counter screen readers read once

The counter replaces its element’s text on every frame and adds no role or live region, so a screen reader that lands on it mid-count reads the number showing at that moment, and nothing is announced as it changes. Give screen readers the final number in a visually hidden copy, and hide the counting one from them with aria-hidden. Write the real number into the HTML as well: search engines and anyone without JavaScript see it, and the counter writes over it when it starts. In React, <ScrollCounter> renders its span empty, so there the hidden copy is the only place the HTML has the number. The generator’s screen-reader copy setting adds the hidden copy and the aria-hidden to its code.

index.html15 lines
<p class="stat">
  <!-- Read once, in full, by screen readers. -->
  <span class="sr-only">12,400+ users</span>
  <!-- What counts. It starts as the real number, so the HTML has it. -->
  <span aria-hidden="true"><span id="users">12,400+</span> users</span>
</p>

<style>
  .sr-only {
    position: absolute; width: 1px; height: 1px; overflow: hidden;
    clip-path: inset(50%); white-space: nowrap;
  }
  /* Every digit the same width, so the line does not shift as it counts. */
  #users { font-variant-numeric: tabular-nums; }
</style>

The whole pattern, with the width held still while it counts: the stat row recipe.

058 options

Every scrollCounter option

to

Required. The number it counts to.

from

0 by default. The number it starts from. One larger than to counts down; negative numbers work too.

format

String(Math.round(n)) by default. A function from the current value to the text shown, called on every frame. Left out, the number is rounded and printed with no separators.

decimals

No default. Prints the value with n.toFixed(decimals). It replaces format entirely, so with a format function, set the decimals inside it instead.

easing

'ease-out' by default. 'ease-out', 'ease-in-out', 'ease-in', 'linear', 'spring', 'bounce', 'elastic', or any function of t. A name it does not know falls back to 'ease-out'.

trigger

{ start: 'top 80%', end: 'top 20%' } by default. Element edge and window edge, as in ScrollTrigger: the count starts when the element’s top is 80% down the window and is done at 20%.

once

true by default. Hold the furthest the reader has scrolled, instead of counting back down when they scroll up. In 2.23.2 and earlier it held the highest eased value instead, so spring and elastic stopped past to and bounce lost its dips; fixed in 2.23.3, which holds the scroll progress and eases after it.

onComplete

No default. Called the first time the eased count reaches to, which with spring, bounce or elastic comes before the end of the window; with once: false, again each time it gets back there. Under reduced motion, at once.

067 questions

Animated counter questions

Q1

How do I make a number count up when it scrolls into view?

Put the number in an element and call scrollCounter('#users', { to: 12400 }) from svg-scroll-draw. It counts from 0 to 12400 as the element’s top moves from 80% to 20% of the window, so the count follows the scroll instead of a timer, and scrolling back up leaves it where it got to unless you set once: false. The generator above writes the call for you.

Q2

How do I format an animated counter with commas, a currency sign or a percent?

Pass a format function. It is called on every frame with the raw value, so make an Intl.NumberFormat once and use it: format: (n) => nf.format(n) + '%'. Without format the number is rounded and printed with no separators (12400). The decimals option is toFixed, also without separators, and it replaces format entirely, so when you use a format function set the decimals in its Intl.NumberFormat options.

Q3

How do I make a count-up animation in React?

Render <ScrollCounter to={12400} /> from svg-scroll-draw/react: it renders a span and takes the counter’s options as props. In the Next.js App Router a function such as format can only be passed from a client component, so put 'use client' at the top of the file that renders it. A new format arrow on every render does not rebuild the counter; changing a value such as to does.

Q4

Can the counter count down, or start from a number other than zero?

Yes. The number shown is from + (to − from) × the eased progress, so from: 2400, to: 180 counts down, and negative numbers work as well. from is 0 when you leave it out.

Q5

Are animated number counters accessible to screen readers?

scrollCounter rewrites the element’s text on every frame and adds no ARIA, so a screen reader that reaches it mid-count reads whatever number is showing. Put the final number in a visually hidden span and mark the counting element aria-hidden="true", so it is read once, in full. For readers who ask their system for reduced motion, the counter writes the final number at once.

Q6

Why does my counter stop before the final number?

The end of its window was never reached. By default the count is done when the element’s top is 20% from the top of the window, and near the bottom of a page it may never scroll that high; with once on, it then stays where it stopped. End it sooner, for example trigger: { start: 'top bottom', end: 'bottom 90%' }, which finishes when the element’s bottom is 10% above the bottom of the window. In 2.23.2 and earlier, a spring or elastic easing with once on could also stop past the final number, on the top of its overshoot; 2.23.3 fixes that, and the count settles on the final number.

Q7

Can the counter run on a timer instead of the scroll?

It is made for the scroll, but every instance has seek(p), which sets the number to from + (to − from) × p, with p clamped to 0–1, and stops following the scroll. Calling seek from a requestAnimationFrame loop plays it over time, which is what the Play button above does. seek takes p as given, without the easing, so apply your own curve to it. Under reduced motion the counter has already written the final number, and seek does nothing.

All of svg-scroll-draw 2.23.3: scrollCounter in the docs, or the text animation generator for words instead of numbers.