Blog//6 min read
Animated counter in React that counts up on scroll
Build a React animated counter that counts up on scroll with easing, Intl formatting, prefix, suffix and decimals, then a stats band from four of them.
MSMarco Schulz, founder of Facade UI
A number that counts up when it scrolls into view is one of the oldest tricks on a marketing page, and it still works, because the eye follows motion. It is also one of the easiest components to get slightly wrong: it fires before the visitor can see it, it jumps at the end, it prints 12480 instead of 12,480, or it animates on every re-render. The free Animated counter in Facade is my answer to all four. Here is how it works and how to build the same thing yourself.
IntersectionObserver or useInView
The counter should start when the element enters the viewport, not on mount. On a long gym or clinic page the stats sit several screens down, and if they count up while the visitor is still reading the hero, they arrive at a static number.
You have two options. The raw IntersectionObserver API gives you a callback when a ref crosses a threshold, and you manage the observer, the cleanup and the "already fired" flag yourself. Or you use useInView from motion/react, which wraps the same API in a hook and returns a boolean. Facade uses the hook:
const inView = useInView(ref, { once, margin: "0px 0px -10% 0px" });Two details in that line matter. once means the counter runs the first time it becomes visible and ignores later scrolls, which is what you want for a stat. The margin shrinks the viewport by 10 percent at the bottom, so the element has to be properly on screen before it starts, not just one pixel over the fold. A visitor who scrolls fast still sees the start of the animation instead of a number that finished while it was hidden behind the browser chrome.
If the visitor has reduced motion enabled, skip the animation and render the final value. useReducedMotion() gives you that flag, and it costs one line.
Easing: why linear counters feel cheap
A counter that increases at a constant rate reads like a stopwatch. A counter that starts fast and slows down as it approaches the target reads like it is landing on a real number. The difference is the easing curve.
Facade's counter uses animate(from, value, { duration, ease: [0.16, 1, 0.3, 1] }) from motion. That cubic bezier is a strong ease-out: it covers most of the distance early and spends the last part of the duration settling the final digits. The default duration is 1.8 seconds, which is long enough to notice and short enough that nobody waits for it.
The animate function drives a plain number, not a DOM property, and calls onUpdate on every frame. In the update callback you format the current value and write it into the element with textContent. Writing text directly, instead of setting React state on every frame, keeps the component from re-rendering sixty times a second.
Formatting with Intl: prefix, suffix, decimals
This is where most hand-rolled counters fall apart. The number has to be formatted the same way on every frame and at the end, otherwise the string width jumps around and the layout jitters.
Intl.NumberFormat does all of it. Pass a locale, set minimumFractionDigits and maximumFractionDigits to the same value, and the thousands separators, decimal mark and digit grouping come out right for that locale without any string hacks. The Facade counter takes a locale prop (default en-US), a decimals prop and an optional formatOptions object for anything else Intl supports, such as notation: "compact" for 1.2K.
Prefix and suffix live outside the formatted number. A $ goes in front, a + or % or min after. Keep them as separate props rather than baking them into the string, so the formatter only ever sees digits.
import { AnimatedCounter } from "@/components/facade/animated-counter";
<p className="text-5xl font-semibold tabular-nums">
<AnimatedCounter value={4.9} decimals={1} suffix="/5" duration={1.4} />
</p>
<p className="text-5xl font-semibold tabular-nums">
<AnimatedCounter value={2400000} prefix="$" formatOptions={{ notation: "compact" }} />
</p>One styling rule I apply every time: tabular-nums. With tabular figures every digit has the same width, so the number does not wobble sideways while it counts. Inter, which Facade's docs use, supports it, and so do most system fonts.
Decimals and the final frame
Two edge cases:
- Decimals during the animation. If the target is
4.9and you format each frame with one decimal, the visitor sees0.0, 1.3, 2.8, 4.1, 4.9. Good. If you format with zero decimals and only add the decimal at the end, the last frame jumps. Format every frame with the final precision. - The last frame.
animatereports the exact target on completion, but the easing can land at4.899999. Rounding happens insideIntlwith the fixeddecimals, so the final text is exactly4.9. Do not do your ownMath.round; let the formatter handle it.
Install the counter with the shadcn CLI. It is free and MIT licensed:
npx shadcn@latest add https://facadeui.com/r/animated-counter.jsonA stats band from four counters
One counter is a detail. Four counters in a row with labels and a trend line is a section, and it is the section I put directly under the hero on almost every local business site. The free Stats band is that section: four StatsBandItem entries, each with a value, an optional prefix, suffix and decimals, a label, and an optional trend string with a trendDirection.
The band adds two things the single counter does not have. The divider lines above each stat animate their scaleX from 0 to 1 when the band scrolls into view, so the row draws itself in. And the trend chip can say that a number went down and that this is good news, through trendDirection="down" with trendPositive left at its default of true. "47 min average arrival, 9 min faster" is a down arrow that should be green.
import { StatsBand } from "@/components/facade/stats-band";
<StatsBand
eyebrow="By the numbers"
title="A gym that shows up for you"
stats={[
{ value: 1850, suffix: "+", label: "Active members", trend: "+12% this year" },
{ value: 4.8, decimals: 1, suffix: "/5", label: "Average Google rating" },
{ value: 62, label: "Classes every week", trend: "+8 new" },
{ value: 6, suffix: " min", label: "Average wait for a squat rack", trend: "-4 min", trendDirection: "down" },
]}
/>The band can also render on an inverted surface with inverted, which is useful between two light sections on a long page. On a gym site I use it right after the hero, then the pricing section, because a visitor who just read "62 classes every week" is ready to see what a membership costs.
The numbers you put in are the whole point. Use real ones from the client's business. A counter that lands on a made up figure is a liability, not a design element.
npx shadcn@latest add https://facadeui.com/r/stats-band.jsonFAQ
Why not animate with React state and setInterval?
Every tick would re-render the component, and setInterval drifts, so the animation stutters on a busy main thread. animate from motion runs on requestAnimationFrame, hands you a value per frame and cleans itself up. Writing textContent in its update callback bypasses React's render cycle entirely.
Can the counter run again when it scrolls back into view?
Yes. Pass once={false}. The hook then reports every entry, and the counter resets to from and counts up again. I rarely use it on marketing pages, because a number that keeps restarting looks less trustworthy than one that lands once.
Does the counter work with server rendering in Next.js?
It is a client component, but the initial server render prints the from value formatted, so the markup is valid and there is no layout shift. The count-up starts after hydration when the element enters the viewport.