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.
- Options
- 8
- Easings
- 7
- Locales
- 5
- Output
- 2
Make your counter
// <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.
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.
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 goodThe 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.
/* 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));
}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.from.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.
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| Locale | 1234567.891, 2 decimals | 4200000, compact |
|---|---|---|
| en-US | 1,234,567.89 | 4.2M |
| en-IN | 12,34,567.89 | 42L |
| de-DE | 1.234.567,89 | 4,2 Mio. |
| fr-FR | 1 234 567,89 | 4,2 M |
| ja-JP | 1,234,567.89 | 420万 |
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.-0.3, which toFixed and Intl.NumberFormat both print as -0. The generator’s code formats those as 0.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.
<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.
Every scrollCounter option
Required. The number it counts to.
0 by default. The number it starts from. One larger than to counts down; negative numbers work too.
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.
No default. Prints the value with n.toFixed(decimals). It replaces format entirely, so with a format function, set the decimals inside it instead.
'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'.
{ 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%.
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.
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.
Animated counter questions
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.
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.
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.
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.
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.
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.
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.