Counter
A number that counts up, down or rolls to its new value, formatted for the provider's locale.
Use it where a number's change is the news: a price switching billing periods, a metric on a dashboard, a stat as it scrolls into view. Don't animate numbers people read in bulk or compare across rows, like table cells, and don't count a value the reader needs immediately, like a balance at checkout. Show those as plain text.
Installation
npm i @youj/motionFor an app already set up with npx @youj/cli init, which installs the peers (@youj/core, @youj/motion, motion and Base UI) and wires the provider. New app? Start at Installation.
Usage
import { Counter } from '@youj/motion'
export function Revenue({ total }: { total: number }) {
return <Counter value={total} format={{ style: 'currency', currency: 'USD', maximumFractionDigits: 0 }} />
}Formatting is plain Intl.NumberFormat, so currency, percent, compact notation, units and fraction digits all work, in any locale. Without one, it formats in the YoujProvider's locale (en-US without a provider). Numerals are tabular, so the width holds steady while the digits change.
Examples
Two modes
tween, the default, counts through every value in between. It suits a number's first arrival.
roll spins each digit in its own column, like an odometer. Columns are keyed by place value, so they keep their position as the number grows a digit. It suits values that update while you watch.
Changing values
Pass a new value at any time. The count retargets from the value it's showing, so there's no jump even mid-animation. Press the buttons below a few times in a row to see it.
In this demo the counter sits in a role="status" region, so a screen reader hears each settled total once. The counter doesn't add one itself.
When it plays
The first play waits until half the number is on screen (inView), so a stat below the fold still gets its moment. Use inView={false} for numbers that are visible from the start, and from to set where the first count begins.
Reference
Propsvalue, from, mode… (8)
Props
| Prop | Type | |
|---|---|---|
valueRequired | number | – |
from | numberStarting value for the first play. default 0 | 0 |
mode | 'tween' | 'roll''tween' counts through values; 'roll' spins each digit like an odometer. default 'tween' | 'tween' |
format | Intl.NumberFormatOptionsIntl.NumberFormat options: currency, percent, compact notation, fraction digits. | – |
locale | stringA BCP 47 locale. Default: the YoujProvider locale, en-US without one. | – |
inView | InViewWhen the first play starts: | – |
duration | DurationToken'instant''fast''base''slow''deliberate'Tween mode's duration. Roll mode rolls on the default 'deliberate' | 'deliberate' |
ease | EasingToken'standard''enter''exit''emphasized''linear'Tween mode's curve. default 'emphasized' | 'emphasized' |
cycle | numberRoll mode, for a value that wraps around (60 for seconds or minutes, 24 for hours): a change of more than half the cycle is read as a wrap, so 00 → 59 rolls one step back, and the tens wheel holds only the digits the cycle reaches (0–5 for 60). | – |
Other props pass through: extends HTMLAttributes<HTMLSpanElement>. | ||
MotionCount, roll, column offset · reduced motion
Motion
| Moment | Tokens | ||
|---|---|---|---|
Counttween mode | duration.deliberateease.emphasized560ms · cubic-bezier(0.6, 0, 0.1, 1) | 560mscubic-bezier(0.6, 0, 0.1, 1) | |
Rollroll mode, each digit column | duration.deliberateease.emphasized560ms · cubic-bezier(0.6, 0, 0.1, 1) | 560mscubic-bezier(0.6, 0, 0.1, 1) | |
| Column offsetper place value, ones first | stagger.tight24ms step | 24ms step |
Reduced motion
Shows the formatted value immediately. New values swap in place.
The emphasized curve winds up briefly, moves fast through the middle and lands slowly, so the final digits are readable before the number stops.
Reduced motion
The formatted value shows at once, with no count and no roll. A new value swaps in place. The number is the information, and it's never delayed.
AccessibilityARIA, announcements
Accessibility
- The formatted value is always in the document, in a visually hidden span, and the moving digits are
aria-hidden. A screen reader hears "$48,210", never a run of intermediate numbers. - A counter doesn't announce changes on its own. If a live value matters, wrap it in a live region such as
role="status", and it's announced once, at its final value.