@sfinterface/numbers
“A number that changes should be seen to change.” Each column is a strip of digits behind a window, and only the columns whose digit moved turn. They turn the way the number went, so counting up out of 9 goes forward to 0 rather than back through eight digits. Formatting is Intl’s, so currency, percentages and any locale come from the platform.
Install
Numbers ships on its own, apart from the rest of the library. Two imports and a value: the stylesheet describes motion and geometry and nothing else, so face, size, weight, colour and tracking are all inherited from wherever you put it.
import { Numbers } from "@sfinterface/numbers";
import "@sfinterface/numbers/styles.css";
<Numbers value={count} />Try it
Only the columns whose digit actually moved turn. Add one to 1,204 and a single wheel goes round; add forty-seven and three do, each by a different distance, on the same clock.
A prefix or suffix travels with the number rather than sitting near it, so a symbol stays against the digits as the figure gains a column or loses one.
Move anything below and the readout answers, with the code underneath rewriting itself as you go. Props at their default are left out, so what you see is the least you would have to write.
<Numbers
value={1204}
/>Formatting
Formatting is Intl.NumberFormat. Currency, percentages, compact notation, numbering systems and every locale come from the platform, not from here.
<Numbers value={1125.64} format={{ style: "currency", currency: "USD" }} />
<Numbers value={0.0241} format={{ style: "percent", maximumFractionDigits: 2 }} />
<Numbers value={48200} format={{ notation: "compact" }} locale="en-US" />Controls
A readout is usually the answer to something a person just pressed. Put it next to the control that changes it and the two read as one thing: the total below moves the moment the quantity does, and only the columns that changed turn.
<button onClick={() => setQuantity(quantity - 1)}>−</button>
<Numbers value={quantity} />
<button onClick={() => setQuantity(quantity + 1)}>+</button>
<Numbers value={quantity * 24.5} format={{ style: "currency", currency: "USD" }} />Locales
A column is a strip of whatever digits the locale uses, so Arabic-Indic, Devanagari and Thai numerals turn exactly as Latin ones do. Grouping, separators, currency placement and direction are Intl’s; nothing here assumes a comma every three digits.
<Numbers value={1204} locale="ar-EG" />
<Numbers value={1204} locale="hi-IN-u-nu-deva" />
<Numbers value={1204} locale="th-TH-u-nu-thai" />
<Numbers value={1204000} locale="en-IN" />
<Numbers value={128400} locale="ja-JP" format={{ style: "currency", currency: "JPY" }} />
<Numbers value={48200} locale="zh-CN" format={{ notation: "compact" }} />Transitions
Five ways for a column to change. Only roll turns through the digits between, so only roll says how far the number moved; the rest swap one glyph for another.
roll | the strip turns, through every digit on the way |
tick | the old digit slides out, the new one slides in |
blur | it dissolves, defocused, in place |
flip | it turns over, like a split-flap board |
scale | it shrinks away and the new one grows |
Props
| Prop | Type | Default | Does |
|---|---|---|---|
value | number | required | The number to show. Changing it is what starts the roll. |
format | Intl.NumberFormatOptions | none | Passed straight to Intl.NumberFormat, so currency, percent, compact notation and fraction digits are the platform's. |
locale | string | string[] | the runtime's | Passed to Intl.NumberFormat. Undefined uses the runtime's own locale. |
transition | "roll" | "tick" | "blur" | "flip" | "scale" | "roll" | How a column gets from one digit to the next. Only roll turns a strip, so only roll says how far the number moved. |
trend | "auto" | "up" | "down" | "auto" | Which way the columns turn. auto takes the direction from the change. |
blur | boolean | true | Defocus each column while it turns, as deeply as it travels far. |
fade | number | 1 | How far the soft edge of the window reaches. 0 is a hard window with the neighbours fully visible; 2 and up is a column you read one digit of. |
softness | number | 0 | How gradually that edge arrives. 0 holds the digit crisp and then lets go quickly; 1 spreads the same reach into an even, eased ramp with no knee in it. |
prefix | ReactNode | none | Set beside the number, before the digits. It travels with them: the box is one inline box, so a symbol stays against the digits as the number gains a column or loses one. |
suffix | ReactNode | none | The same, after the digits. Both are read out with the number, so a screen reader hears the whole reading rather than the figure alone. |
duration | number | 520 | Milliseconds for one turn, overriding --sfi-numbers-roll. |
label | string | the formatted value | What a screen reader is given instead of thirty cells per column. |
Everything else is spread onto the root span.
Theming
Every value is a custom property. Set one on :root for the app, on a wrapper for a region, or inline for a single readout.
| Token | Default | Is |
|---|---|---|
--sfi-numbers-roll | 520ms | how long a column takes to turn |
--sfi-numbers-exit | 240ms | how long a leaving column takes to go |
--sfi-numbers-cell | 1.4em | the pitch the digits are stacked at |
--sfi-numbers-blur | 0.09em | the deepest a fast column smears |
--sfi-numbers-fade | 1 | how far the window's soft edge reaches |
--sfi-numbers-fade-roll | 1.9 | how much further it reaches mid-turn |
--sfi-numbers-softness | 0 | how gradually the edge arrives, 0 crisp to 1 even |
--sfi-numbers-gap | 0em | the air between a prefix or suffix and the digits |
--sfi-numbers-ease | cubic-bezier(0.32, 0.72, 0, 1) | the curve a column turns on |
--sfi-numbers-ease-exit | cubic-bezier(0.4, 0, 1, 1) | and the one it leaves on |
:root {
--sfi-numbers-roll: 700ms;
--sfi-numbers-cell: 1.25em;
}Accessibility
The visible digits are aria-hidden and a single formatted string sits behind them, so a screen reader says one thousand two hundred and four, not twelve separate glyphs.
With prefers-reduced-motion: reduce the columns do not turn, blur or fade. The number changes to its new value, and the component never animates a thing.
Requirements
React 18 or 19. No dependencies. Types are bundled. It renders on the server with the formatted value already in the markup, so there is no flash of an empty box and nothing to guard.