@sfinterface/numbersThe San FranciscoInterface

@sfinterface/numbers

/ˈnʌm.bəz/Noun [plural]1. Arithmetical values. 2. A figure that turns rather than being replaced.

“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.

1,204

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.

1,204
format
transition
prefix
suffix
trend
fade1
softness0
duration520ms
blur
<Numbers
  value={1204}
/>

Formatting

Formatting is Intl.NumberFormat. Currency, percentages, compact notation, numbering systems and every locale come from the platform, not from here.

$1,125.64Currency
2.41%Percent
48KCompact
240Counting down
<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.

Quantity3
Total$73.50
Weekly reach
48,200
<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.

١٬٢٠٤Arabic, Egypt
१,२०४Hindi, Devanagari
๑,๒๐๔Thai digits
12,04,000Indian grouping
¥128,400Japanese yen
4.8万Chinese compact
<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.

rollthe strip turns, through every digit on the way
tickthe old digit slides out, the new one slides in
blurit dissolves, defocused, in place
flipit turns over, like a split-flap board
scaleit shrinks away and the new one grows

Props

PropTypeDefaultDoes
valuenumberrequiredThe number to show. Changing it is what starts the roll.
formatIntl.NumberFormatOptionsnonePassed straight to Intl.NumberFormat, so currency, percent, compact notation and fraction digits are the platform's.
localestring | string[]the runtime'sPassed 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.
blurbooleantrueDefocus each column while it turns, as deeply as it travels far.
fadenumber1How 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.
softnessnumber0How 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.
prefixReactNodenoneSet 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.
suffixReactNodenoneThe same, after the digits. Both are read out with the number, so a screen reader hears the whole reading rather than the figure alone.
durationnumber520Milliseconds for one turn, overriding --sfi-numbers-roll.
labelstringthe formatted valueWhat 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.

TokenDefaultIs
--sfi-numbers-roll520mshow long a column takes to turn
--sfi-numbers-exit240mshow long a leaving column takes to go
--sfi-numbers-cell1.4emthe pitch the digits are stacked at
--sfi-numbers-blur0.09emthe deepest a fast column smears
--sfi-numbers-fade1how far the window's soft edge reaches
--sfi-numbers-fade-roll1.9how much further it reaches mid-turn
--sfi-numbers-softness0how gradually the edge arrives, 0 crisp to 1 even
--sfi-numbers-gap0emthe air between a prefix or suffix and the digits
--sfi-numbers-easecubic-bezier(0.32, 0.72, 0, 1)the curve a column turns on
--sfi-numbers-ease-exitcubic-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.