Count-Up Stats
A count-up is not decoration — it is a way of drawing the eye to a number that would otherwise sit quietly in a row of three. That is also why it fails so often: the numbers jitter as digits change width, the animation replays every time the section scrolls back into view, and the easing makes the last ten percent take as long as the first ninety.
Live demo
The prompt
Create a row of statistics whose numbers count up once when the section enters the viewport. Markup: - Each stat is a container with a number element and a label beneath it. - The final value lives in a data attribute on the number element, and the visible text starts at 0. Never ship the final number as visible text and then overwrite it, because that flashes the answer before the animation. Behaviour: - Use IntersectionObserver with threshold 0.4 and unobserve after the first intersection. The animation must run exactly once per page visit; replaying on every scroll is the most common defect. - Drive the animation with requestAnimationFrame and a timestamp, not with a setInterval. Compute progress as elapsed / duration, clamped to 1. - Duration 900 to 1400ms depending on the magnitude of the number. - Ease out, not linear: progress = 1 - Math.pow(1 - t, 3). A linear count reads as a slot machine; an easing count decelerates into its final value. - Format every frame with Intl.NumberFormat using the page locale, and fix the number of decimals so the width does not jump. Layout stability: - Reserve the final width before the animation starts. The simplest method is font-variant-numeric: tabular-nums plus a min-width set in ch units to the final string length. - Never let the number element reflow the surrounding layout frame by frame. Accessibility: - Under prefers-reduced-motion: reduce, set the final value immediately with no animation. - The number is content, not decoration: keep it as real text, do not put it in aria-hidden, and do not announce intermediate values to screen readers. Constraints: - If the section is already visible on load, still animate once, but start immediately rather than waiting for a scroll that may never happen. - Values with a currency symbol keep the symbol outside the animated portion.
What actually makes it work
font-variant-numeric: tabular-nums. Proportional figures are narrower for 1 than for 8, so a counting number visibly jitters and shoves its label sideways. Tabular figures give every digit the same advance width, and the number stays still while its value changes.unobserveafter the first hit. An observer that stays connected fires again on every entry, and the count restarts each time the reader scrolls up and back. Unobserving makes the effect a one-time arrival rather than a loop.- Ease-out, not linear. Linear counting is how a slot machine reads. A cubic ease-out compresses the last digits into the final few frames, which is what makes the number feel like it settled rather than stopped.
- Decimals fixed in the formatter. A value that goes 3.9 → 4.0 → 4.2 changes width twice at the end. Fixing the fraction digits at the start keeps the element's width constant for the whole run.
- Reduced motion means the final state, immediately. Not a faster animation, and not a blank element. The number is information; removing it to respect a preference would be worse than the motion.
Compatibility and performance
| Aspect | Detail |
|---|---|
| IntersectionObserver | Universal in current browsers; a scroll-position check is a fine fallback. |
| Intl.NumberFormat | Universal. Handles separators, decimals and currency per locale. |
| Cost | One text update per frame for roughly a second. Text nodes trigger layout, so keep the number in its own element with a reserved width. |
| Many stats | Twelve numbers animating at once is twelve layouts per frame. Cap it at four per view and stagger the starts. |
One practical note: because the count is text rather than transform, it is the one effect in this library that genuinely costs layout. That is acceptable for a hero row of three numbers and unacceptable for a table of forty.
When not to use it
Don't animate a number the reader needs to compare. Three numbers counting at different speeds cannot be compared until they finish. If the row exists to be compared, show the values and skip the effect.
Don't count up from zero when zero is misleading. A price that climbs from 0 to 4,200 reads as a total accumulating; if it is a price, that framing is wrong.
Don't use it in a dashboard. Metrics that update live already move; adding an entrance animation to every refresh makes the screen impossible to read.