Eased Number Counter
A number tweens from one value to another on `power2.inOut` rather than linearly, so it reads as loading rather than as a progress meter — slow to commit, quick through the middle, easing onto the final figure. The thing being tweened is a plain numeric object; the string on screen is formatted from it on every update and written with `textContent`, so React never re-renders while the count runs. A transparent sizer reserves the width of the wider endpoint, so nothing after the counter moves as digits are added.
Eased Number Counter
Live preview
The real component, running in this page.
Install
Copies the source into your project. Nothing is imported from us at runtime.
npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/eased-number-counter.jsonPrompt
The whole technique, written for an agent.
Build it with your agent
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
Source
3 files, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| to | number | — | The value to land on. |
| from | number | = 0 | The value to start from. |
| duration | number | = 1.6 | Seconds of counting. |
| ease | string | = 'power2.inOut' | Any GSAP ease string. none makes it read as a meter. |
| delay | number | = 0 | Seconds to wait after the trigger before counting. |
| locale | string | — | BCP 47 tag. Omitted uses the runtime default locale. |
| decimals | number | = 0 | Fixed fraction digits. Also the rounding. |
| grouping | boolean | = true | Thousands separators. |
| prefix | string | = '' | Literal text before the number, e.g. a currency sign. |
| suffix | string | = '' | Literal text after the number, e.g. a percent sign. |
| startOnView | boolean | = true | Count when scrolled into view rather than on mount. |
| className | string | — | Merged onto the outer span via cn. |
| onComplete | () => void | — | Fired once the final figure is written. |
How it works
The technique in order, not the code in order.
A local object
{ v: from }is the tween target.gsap.to(counter, { v: to, duration, ease })interpolates the number; the DOM is not the tween target at all.onUpdatecalls awrite()closure that doesel.textContent = prefix + format.format(counter.v) + suffix. WritingtextContentrather than React children means the tween never touches the reconciler.The
Intl.NumberFormatis built once in auseMemokeyed on[locale, decimals, grouping]— three primitives, so the memo actually holds.minimumFractionDigits === maximumFractionDigitsalso performs the rounding, so noMath.roundortoFixedis needed in the tick.onCompleteassignscounter.v = toand writes once more. Tween interpolation lands on values like 99.999999997, and a headline number stopping one unit short of itself is a real bug report.Before the tween starts,
write()runs once withv = from, so the live node is never empty on the first frame and the element does not pop into existence when the tween begins.The width reservation compares
format.format(to).lengthagainstformat.format(from).lengthand renders the longer of the two as atext-transparentcell stacked under the live node in aninline-grid, both atcol-start-1 row-start-1.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| DURATION | = 1.6 | Seconds. Under ~1s the eye cannot track the digits and the effect is just a flicker before the answer; past ~2.5s the reader has finished the sentence around it and is waiting on a number that has stopped being interesting. |
| EASE | = 'power2.inOut' | The in-out pair is what separates a counter from a progress bar. none reads as a machine metering out a value; power2.out starts at full speed and looks like it began before you looked. The slow start is the part that reads as "committing". |
| VIEW_THRESHOLD | = 0.6 | 60% of the element visible before counting. A stat that starts at 1% visibility has finished before it clears the fold, so the reader arrives at a static number and never sees the effect. |
| decimals | = 0 | Also the rounding rule, via matched minimumFractionDigits/maximumFractionDigits. Leaving them unmatched lets Intl vary the digit count between frames, so the string length oscillates and the number jitters horizontally. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
Writes the final formatted figure immediately and fires onComplete, with no tween created at all. It is never left blank and never runs a shortened tween that still flickers through intermediate values.
The effect returns tween.kill(). Because the tween target is a local object rather than a DOM node, nothing is left written to the element beyond the final string, and there are no inline styles to clear.
Scroll the stat into view: the number counts from its start value, decelerates, and stops on exactly the stated figure — check the last digit. Nothing to the right of the number moves while it counts. Enable reduced motion, reload, and the final figure is present on first paint. Force a parent re-render mid-count and the number keeps counting rather than snapping backwards.
Pitfalls
- Constructing
new Intl.NumberFormat()insideonUpdate. It costs tens of microseconds and it is being built 60-120 times a second per counter; a page of eight stats spends milliseconds a frame building formatters it throws away. - Tweening the string, or holding the value in
useState. AsetStateper frame re-renders the subtree at display refresh rate, and worse, any unrelated parent render snaps the number back to the last committed value mid-count. - Rendering the live value as React children. React will diff and overwrite whatever the tween wrote, so the count freezes on the first parent re-render.
- Not snapping to the exact target in
onComplete. Floating-point interpolation lands near, not on, the target — the counter finishes at 99,999 instead of 100,000. - Letting the box size itself from the current text. Every added digit widens the element and shoves the following copy sideways; the count ends up looking like a layout bug.
- Using
visibility: hiddenfor the sizer. That removes it from the accessibility tree as well, and the transparent copy is the only readable text on the element — the live node isaria-hiddenbecause it is mid-count garbage.
Notes
- The
onCompletecallback is held in a ref that is reassigned on every render, so passing an inline arrow from the caller does not restart the count on every parent render. - A number formatting *options object* prop was deliberately not exposed: an object literal is a new reference every render, which would rebuild the formatter, invalidate the effect, and restart the tween continuously.