Count-Up Stats

Figures that animate once, then keep their width.

← All motions

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.

trigger: scroll (enter) IntersectionObserver + rAF tabular figures

Live demo

0 styles documented
0 downloads this month
0 median build time

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

Compatibility and performance

AspectDetail
IntersectionObserverUniversal in current browsers; a scroll-position check is a fine fallback.
Intl.NumberFormatUniversal. Handles separators, decimals and currency per locale.
CostOne 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 statsTwelve 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.