Counter up
Free · MITCounts from 0 (or from) to the value with an ease-out curve when it scrolls into view, with locale formatting, once or replay-on-return, and counts from the current value when it changes.
Installation
$ npx shadcn@latest add https://goodui.dev/r/counter-up.jsonThe file lands in components/goodui/counter-up.tsx and is yours to edit. With the @goodui registry added, npx shadcn@latest add @goodui/counter-up works too.
Usage
import { CounterUp } from "@/components/goodui/counter-up"
const stats = [
{ label: "Bookings this year", value: 12480 },
{ label: "Revenue", value: 384920, format: { style: "currency", currency: "USD", maximumFractionDigits: 0 } as Intl.NumberFormatOptions },
{ label: "Average rating", value: 4.9, suffix: " / 5" },
{ label: "Clients who rebook", value: 68, suffix: "%" },
]
export function Stats() {
return (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{stats.map((s, i) => (
<div key={s.label} className="rounded-xl border bg-card p-4">
<div className="text-xs text-muted-foreground">{s.label}</div>
<CounterUp
value={s.value}
format={s.format}
suffix={s.suffix}
delay={i * 0.08}
className="mt-1 text-2xl font-semibold tracking-tight"
/>
</div>
))}
</div>
)
}When to use
- Stats rows on a landing page ("12,480 bookings this year") that should count when they scroll into view.
- KPI cards on first load, so the numbers draw the eye once.
- A total that changes later (new clients added): it counts from the current value to the new one.
- A live value that changes often and should feel like an odometer: use number-flow.
- Numbers in tables or forms that people read and compare: plain text, motion slows reading there.
- Showing a change (+8.2%) rather than a total: use delta-badge.
| If you need | Use |
|---|---|
| Count once when the section first appears | defaults (once is true) |
| Replay every time it comes back into view | once={false} |
| Inside a scrolling panel instead of the page | root={panelRef} |
| Start only when fully visible | amount={1} |
| Stagger a row of stats | delay={index * 0.08} |
Examples
Count from a baseline and chain an action
<CounterUp
value={2847}
from={2600}
duration={0.9}
onComplete={() => setShowBadge(true)}
/>Replay inside a scroll container
const panel = React.useRef<HTMLDivElement>(null)
<div ref={panel} className="h-64 overflow-y-auto">
…
<CounterUp value={1260} suffix=" h" once={false} root={panel} amount={0.9} />
</div>API reference
| Prop | Type | Default |
|---|---|---|
| value* Target value. Changing it later counts from the current value to the new one. | number | — |
| from Start value for the first count. | number | 0 |
| duration Seconds the count takes. | number | 1.6 |
| delay Seconds to wait after the element enters the viewport. | number | 0 |
| ease motion easing for the count; defaults to a strong ease-out [0.16, 1, 0.3, 1]. | Easing | Easing[] | [0.16, 1, 0.3, 1] |
| format Intl.NumberFormat options, e.g. { style: "currency", currency: "USD" }. | Intl.NumberFormatOptions | — |
| locale Locale passed to Intl.NumberFormat ("de-DE" gives 1.234,5). Defaults to the GooduiLocaleProvider language, else en-US. | string | — |
| prefix Text placed before the formatted number. | string | "" |
| suffix Text placed after the formatted number, e.g. "%" or " / 5". | string | "" |
| once Count only the first time it scrolls into view. With false it resets when it leaves and replays on return. | boolean | true |
| amount Share of the element that must be visible before it starts, 0 to 1. | number | 0.5 |
| root Scroll container to observe instead of the viewport. | React.RefObject<Element | null> | — |
| onComplete Called when a count finishes (also right away when reduced motion skips it). | () => void | — |
Also accepts every <span> attribute.
Accessibility
- The final formatted value sits in an sr-only span from the start; the counting text is aria-hidden, so screen readers read the real number, not intermediate frames.
- The rest of the props go to the root <span>, so you can add aria-describedby or an id.
Motion
- Counts with motion's animate() over duration (default 1.6 s) on the ease [0.16, 1, 0.3, 1], starting when amount (default 0.5) of the element is visible, after delay.
- Intermediate frames keep the target's decimal places (4.9 counts as 0.0, 0.1…), unless format sets fraction digits or uses currency.
- With once={false} it resets to from when it leaves the view and counts again on return.
- Reduced motion: the final value is written immediately and onComplete still fires.
- Each frame writes textContent directly on the DOM node, so counting never re-renders React. The animation is stopped on unmount or when value changes mid-count.
Notes for AI
Coding agents read these rules through MCP and llms.txt, or paste this page with Copy page.
- Pass the target as a number to value; format with format and locale, not by building a string.
- Use suffix for units ("%", " h", " / 5") and format.style "currency" for money.
- For a counter inside an overflow container, pass that container's ref as root, or it will count as soon as the page shows it.
- To replay on a button click, change its key (it remounts and counts from from); there is no replay method.
- It renders an inline-block span: put typography classes on it directly.
FAQ
Why does it start at 0 before scrolling into view?
The server and the first render show from (default 0). The count starts when the element is in view, so a stat below the fold is still at 0 until the user gets there.
What happens when value changes after the first count?
It counts from the number currently shown to the new value with the same duration and ease, instead of restarting from from.
Related
Digits roll to the new value with locale formatting, prefixes and suffixes.
KPI card with seven variants, tones, shadows and sizes: count-up or rolling NumberFlow values, auto-toned delta badges (invertible for no-show rates), line, area or bar sparklines that draw in, icons, footers and a skeleton state.
"+12.4%" change badge whose digits roll, arrow rotates to the new direction and background pulses green or red, with an inverted mode for metrics where down is good.
Reveals a heading word by word or letter by letter with a staggered blur, fade and rise when it scrolls into view or on demand.