Animated number
A tabular figure that rolls to its new value when it changes.
Friday freight run
$60,990.50
2 of 4 invoices selected
import { AnimatedNumber } from "@oration/canon/components/animated-number";import { Button } from "@oration/canon/components/button";import { Checkbox } from "@oration/canon/components/checkbox";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Hero() { const invoices = [ { id: "INV-20931", supplier: "Northwind Freight", amount: 48210.5 }, { id: "INV-20944", supplier: "Halcyon Logistics", amount: 12780 }, { id: "INV-20952", supplier: "Orchard Street Supply", amount: 6395.25 }, { id: "INV-20967", supplier: "Northwind Freight", amount: 21040 }, ]; const [selected, setSelected] = React.useState<string[]>([ "INV-20931", "INV-20944", ]); const chosen = invoices.filter((invoice) => selected.includes(invoice.id)); const total = chosen.reduce((sum, invoice) => sum + invoice.amount, 0); return ( <div className="flex w-full max-w-md flex-col overflow-hidden rounded-xl bg-card shadow-border"> <div className="flex flex-col gap-1 p-4"> <p className="text-13 text-muted-foreground"> Friday freight run </p> <p className="text-2xl font-semibold text-foreground"> <AnimatedNumber value={total} format={{ style: "currency", currency: "USD" }} /> </p> <p className="text-13 text-muted-foreground"> <AnimatedNumber value={chosen.length} className="text-foreground" />{" "} of 4 invoices selected </p> </div> <ul className="flex flex-col border-t border-border px-2 py-2"> {invoices.map((invoice) => ( <li key={invoice.id}> <label className="flex h-9 items-center gap-3 rounded-lg px-2 text-13 hover:bg-muted"> <Checkbox checked={selected.includes(invoice.id)} onCheckedChange={(checked) => setSelected((list) => checked ? [...list, invoice.id] : list.filter( (id) => id !== invoice.id, ), ) } /> <span className="flex-1 text-foreground"> {invoice.supplier} </span> <span className="font-mono text-xs text-muted-foreground"> {invoice.id} </span> <span className="w-24 text-right text-foreground tabular-nums"> {invoice.amount.toLocaleString("en-US", { style: "currency", currency: "USD", })} </span> </label> </li> ))} </ul> <div className="flex justify-end border-t border-border bg-muted/50 px-4 py-3"> <Button type="button" disabled={chosen.length === 0} onClick={() => toast.add({ type: "success", title: "Payment run scheduled", description: `${chosen.length} invoices, Friday, Oct 2 at 2:00 PM CT.`, }) } > Schedule run </Button> </div> </div> );}Usage#
Animated number is a tabular figure that turns its digits to a new value when the value changes, built on @sfinterface/numbers. Each digit is a strip behind a fixed window, and only the columns whose digit moved turn, in the direction the number went. It is for numbers that move because of something the person just did: a tab count after a filter, a credit budget while a slider drags, a payment run total as invoices are added. It inherits face, size, weight and color from its surroundings, so it drops into a stat, a tab or a sentence. The mistake is using it for decoration: it only animates on change, and a figure that never changes should be plain text.
When to use
- For a count or total that updates in place after an action: tab counts after a bulk change, About 1,240 suppliers as filters toggle.
- Beside a slider or stepper, so the figure keeps up with the thumb: a monthly credit budget, a payment threshold.
- For live counters that tick while the person watches: credits left, calls in queue, invoices processed today.
- With
formatfor currency, percents and compact notation, so the formatting stays right as it turns.
When not to use
- For numbers that are read once and never change on screen, such as table cells or a record's created date. Use plain text with
tabular-nums. - For a headline figure with a label, a delta and a trend. Use Stat strip
- For a level against a limit, where the picture matters more than the digits. Use Meter
- For text that streams in, such as a model's reply. Use Streaming text
- For a short label that changes, such as Pause to Resume. Use Text swap
The Tabular Figures Rule
tabular-nums for you, so digits keep their width while they turn and columns stay aligned.Motion grammar
The Roll Rule
roll transition, the only one that turns through the digits in between and so shows how far the number moved. tick, blur, flip and scale exist in the library but aren't part of the house grammar.Anatomy#
- Prefix. Optional node before the digits, such as
+on a delta. It sits in the same inline box, so it stays against the digits as columns come and go. - Digit column. A strip of digits behind a window one cell tall. Only columns whose digit changed turn, each by its own distance, on one clock.
- Symbols. Currency signs, group separators, decimal points and percent signs from
format. They don't turn; they enter and leave as the width changes. - Suffix. Optional node after the digits. Keep units in a muted span beside it when they shouldn't be read as part of the figure.
Examples#
Formats
format takes Intl.NumberFormat options: currency, percent, a plain count and compact notation. Switch the period to watch each one roll.
- Paid
- $842,300.40
- Straight-through
- 72%
- Invoices
- 1,208
- Open payables
- $4.9M
import { AnimatedNumber } from "@oration/canon/components/animated-number";import { SegmentedControl } from "@oration/canon/components/segmented-control";import * as React from "react";export function Formats() { const [period, setPeriod] = React.useState<"this" | "last">("this"); const data = period === "this" ? { paid: 842300.4, stp: 0.72, invoices: 1208, ap: 4_860_000 } : { paid: 796150.9, stp: 0.68, invoices: 1164, ap: 5_120_000 }; const figures = [ { label: "Paid", value: data.paid, format: { style: "currency", currency: "USD" } as const, }, { label: "Straight-through", value: data.stp, format: { style: "percent" } as const, }, { label: "Invoices", value: data.invoices, format: undefined }, { label: "Open payables", value: data.ap, format: { style: "currency", currency: "USD", notation: "compact", maximumFractionDigits: 1, } as const, }, ]; return ( <div className="flex w-full max-w-xl flex-col items-start gap-4"> <SegmentedControl label="Period" value={period} onValueChange={setPeriod} options={[ { value: "this", label: "This week" }, { value: "last", label: "Last week" }, ]} /> <dl className="grid w-full grid-cols-2 gap-4 sm:grid-cols-4"> {figures.map((figure) => ( <div key={figure.label} className="flex flex-col gap-1"> <dt className="text-13 text-muted-foreground"> {figure.label} </dt> <dd className="text-lg font-semibold text-foreground"> <AnimatedNumber value={figure.value} format={figure.format} /> </dd> </div> ))} </dl> </div> );}Prefix and suffix
prefix and suffix become part of the animated value, for a sign on a delta or a unit that belongs to the figure. Units that never change can sit outside in plain text.
Early-pay capture +4.1 pts against the previous period
Days payable outstanding 38 days
import { AnimatedNumber } from "@oration/canon/components/animated-number";import { SegmentedControl } from "@oration/canon/components/segmented-control";import * as React from "react";export function PrefixSuffix() { const [range, setRange] = React.useState<"30" | "90">("30"); const delta = range === "30" ? 4.1 : 11.6; const days = range === "30" ? 38 : 42; return ( <div className="flex w-full max-w-md flex-col items-start gap-4"> <SegmentedControl label="Range" value={range} onValueChange={setRange} options={[ { value: "30", label: "30 days" }, { value: "90", label: "90 days" }, ]} /> <div className="flex flex-col gap-2 text-13"> <p className="text-muted-foreground"> Early-pay capture{" "} <AnimatedNumber value={delta} prefix="+" suffix=" pts" format={{ maximumFractionDigits: 1 }} className="font-medium text-foreground" />{" "} against the previous period </p> <p className="text-muted-foreground"> Days payable outstanding{" "} <AnimatedNumber value={days} className="font-medium text-foreground" />{" "} days </p> </div> </div> );}Counts on view tabs
The counts beside each view move when a bulk action shifts invoices between them, the way the members directory does.
import { AnimatedNumber } from "@oration/canon/components/animated-number";import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function ViewTabs() { const [view, setView] = React.useState<"open" | "approved" | "paid">( "open", ); const [counts, setCounts] = React.useState({ open: 48, approved: 23, paid: 164, }); const tabs = [ { value: "open", label: "Open" }, { value: "approved", label: "Approved" }, { value: "paid", label: "Paid" }, ] as const; return ( <div className="flex w-full max-w-md flex-col gap-4"> <div role="toolbar" aria-label="Invoice views" className="flex gap-1" > {tabs.map((tab) => ( <button key={tab.value} type="button" aria-pressed={view === tab.value} onClick={() => setView(tab.value)} className={cn( "flex h-7 items-center gap-1.5 rounded-lg px-2.5 text-13 outline-none transition-colors duration-150 focus-visible:ring-3 focus-visible:ring-ring/40", view === tab.value ? "bg-muted font-medium text-foreground" : "text-muted-foreground hover:text-foreground", )} > {tab.label} <AnimatedNumber value={counts[tab.value]} className="text-xs text-muted-foreground" /> </button> ))} </div> <div className="flex gap-2"> <Button type="button" variant="outline" size="sm" disabled={counts.open < 5} onClick={() => { setCounts((c) => ({ ...c, open: c.open - 5, approved: c.approved + 5, })); toast.add({ title: "5 invoices approved" }); }} > Approve 5 invoices </Button> <Button type="button" variant="ghost" size="sm" disabled={counts.approved === 0} onClick={() => { setCounts((c) => ({ ...c, approved: 0, paid: c.paid + c.approved, })); toast.add({ title: "Approved invoices paid" }); }} > Pay approved </Button> </div> </div> );}In a sentence
It inherits the paragraph's size and color, so an estimate can update inside running text as filters change.
About 3,127 suppliers match
import { AnimatedNumber } from "@oration/canon/components/animated-number";import { Checkbox } from "@oration/canon/components/checkbox";import * as React from "react";export function InASentence() { const filters = [ { id: "net30", label: "Net 30 terms", factor: 0.62 }, { id: "w9", label: "W-9 on file", factor: 0.81 }, { id: "ach", label: "Paid by ACH", factor: 0.54 }, ]; const [on, setOn] = React.useState<string[]>(["w9"]); const estimate = Math.round( filters.reduce( (count, filter) => on.includes(filter.id) ? count * filter.factor : count, 3860, ), ); return ( <div className="flex w-full max-w-sm flex-col gap-3"> <fieldset className="flex flex-col gap-2"> <legend className="mb-1 text-13 font-medium text-foreground"> Supplier cohort </legend> {filters.map((filter) => ( <label key={filter.id} className="flex items-center gap-2 text-13 text-foreground" > <Checkbox checked={on.includes(filter.id)} onCheckedChange={(checked) => setOn((list) => checked ? [...list, filter.id] : list.filter((id) => id !== filter.id), ) } /> {filter.label} </label> ))} </fieldset> <p className="text-13 text-muted-foreground"> About{" "} <AnimatedNumber value={estimate} className="font-medium text-foreground" />{" "} suppliers match </p> </div> );}Locales
locale defaults to en-US. Pass another and grouping, separators and digits come from Intl.NumberFormat, while the columns still turn the same way.
- United States
- 1,204,000
- India
- 12,04,000
- Germany
- 1.204.000
import { AnimatedNumber } from "@oration/canon/components/animated-number";import { Button } from "@oration/canon/components/button";import * as React from "react";export function Locales() { const [amount, setAmount] = React.useState(1204000); const locales = [ { locale: "en-US", label: "United States" }, { locale: "en-IN", label: "India" }, { locale: "de-DE", label: "Germany" }, ]; return ( <div className="flex w-full max-w-sm flex-col gap-4"> <dl className="flex flex-col gap-2"> {locales.map((item) => ( <div key={item.locale} className="flex items-baseline justify-between gap-3 text-13" > <dt className="text-muted-foreground">{item.label}</dt> <dd className="font-medium text-foreground"> <AnimatedNumber value={amount} locale={item.locale} /> </dd> </div> ))} </dl> <Button type="button" variant="outline" size="sm" onClick={() => setAmount((value) => value + 48250)} > Add a batch </Button> </div> );}Live counter
A figure that ticks while the person watches. Only the digits that change roll.
import { AnimatedNumber } from "@oration/canon/components/animated-number";import { Button } from "@oration/canon/components/button";import * as React from "react";export function LiveCounter() { const [running, setRunning] = React.useState(true); const [processed, setProcessed] = React.useState(1182); React.useEffect(() => { if (!running) return; const id = window.setInterval( () => setProcessed( (count) => count + 1 + Math.floor(Math.random() * 3), ), 1200, ); return () => window.clearInterval(id); }, [running]); return ( <div className="flex w-full max-w-xs flex-col gap-3 rounded-xl bg-card p-4 shadow-border"> <div className="flex items-baseline justify-between gap-3"> <span className="text-13 text-muted-foreground"> Invoices captured today </span> <AnimatedNumber value={processed} className="text-lg font-semibold text-foreground" /> </div> <Button type="button" variant="outline" size="sm" onClick={() => setRunning((value) => !value)} > {running ? "Pause capture" : "Resume capture"} </Button> </div> );}States#
$96,250.00
import { AnimatedNumber } from "@oration/canon/components/animated-number";import { Button } from "@oration/canon/components/button";import * as React from "react";export function Direction() { const [amount, setAmount] = React.useState(96250); return ( <div className="flex w-full flex-col items-center gap-4"> <p className="text-3xl font-semibold text-foreground"> <AnimatedNumber value={amount} format={{ style: "currency", currency: "USD" }} /> </p> <div className="flex gap-2"> <Button type="button" variant="outline" size="sm" onClick={() => setAmount((value) => value + 1250)} > Add Halcyon invoice </Button> <Button type="button" variant="outline" size="sm" disabled={amount < 1250} onClick={() => setAmount((value) => value - 1250)} > Remove Halcyon invoice </Button> </div> </div> );}| State | Treatment |
|---|---|
| First render | The formatted value is in the server markup and drawn statically. Nothing animates on mount. |
| Increasing | Changed columns turn forward over 450ms on the house drawer curve, cubic-bezier(0.32, 0.72, 0, 1). Counting up out of 9 goes forward to 0, not back through eight digits. |
| Decreasing | Columns turn the other way, so direction reads without an arrow. |
| Fast turns | A column that travels far defocuses slightly while it moves, as deep as it travels, and the window's soft edge reaches further mid-turn. |
| Entering and leaving columns | A digit or separator that appears or disappears (999 to 1,000) comes and goes over 240ms. |
| Reduced motion | With prefers-reduced-motion: reduce, columns don't turn, blur or fade. The new value replaces the old one instantly. |
Behavior#
- Renders one inline
<span>of plain DOM, no shadow root. Style it throughclassNameand the parent: face, size, weight, color and tracking are inherited. - The stylesheet is imported once at the top of canon's
globals.css, ahead of Tailwind, so itsarclayer sits below every utility. localedefaults toen-US, so grouping is1,184,250. Pass another locale, such as"en-IN"or"ar-EG", to get its grouping and digits.formattakes anyIntl.NumberFormatoptions:{ style: "currency", currency: "USD" },{ style: "percent" },{ notation: "compact" },{ maximumFractionDigits: 1 }.- Turns run 450ms on the library's default curve, which is the house drawer curve. Pass
durationonly with a reason. trendis"auto"by default, so direction comes from the change. Force"up"or"down"when a value wraps, such as a countdown that resets.- Every other prop, including
id,titleandaria-*, is spread onto the root span, andrefreaches it.
Do and don't#
format for money and percents, so decimals, signs and grouping stay correct as the value turns.prefix="$". Cents lose their trailing zero and negatives come out as $-1,200.Content#
- Keep units outside the number in a muted span when they don't change: 2,500 credits, not a suffix read with it.
- Use
prefixfor a sign on a delta (+12) andsuffixfor a unit that belongs to the figure (42% fromformatis better than a%suffix). - Say when a count is an estimate in the words around it: About 1,240 suppliers match.
- Round to what people act on.
{ notation: "compact" }for large totals in tight spaces ($1.2M), full figures where amounts are reconciled.
Accessibility#
- The digit strips are
aria-hidden, and one formatted string sits behind them with the prefix and suffix, so it is read as one value rather than digit by digit. - Pass
labelwhen the figure needs words a screen reader should hear instead, such as 1,204 invoices captured. - Changes aren't announced. When a new value matters to someone who can't see it, put it in a polite live region or say it in a toast.
- Under reduced motion the new value appears at once; there is no turn, blur or fade.
- Tabular figures keep the width steady, so surrounding text doesn't jump while digits turn.
Design tokens#
| Token | Used for |
|---|---|
tabular-nums | Set on every animated number |
cubic-bezier(0.32, 0.72, 0, 1) | The house drawer curve, 450ms per turn |
--sfi-numbers-exit, 240ms | Columns and symbols leaving |
--sfi-numbers-cell, 1.4em | The pitch the digits are stacked at |
currentColor, inherited font | Color, size and weight come from the parent |
API reference#
AnimatedNumber
A rolling tabular figure. Renders @sfinterface/numbers with the house duration and an en-US default locale.
Other props spread onto NumbersProps from @sfinterface/numbers, so every span attribute reaches the root..
| Prop | Type | Default | Description |
|---|---|---|---|
valueRequired | number | No default | The number to show. Changing it starts the turn. |
format | Intl.NumberFormatOptions | No default | How the number is formatted, such as currency or percent. |
locale | string | string[] | "en-US" | Passed to Intl.NumberFormat. |
prefix | ReactNode | No default | Before the digits, read with the value. |
suffix | ReactNode | No default | After the digits, read with the value. |
trend | "auto" | "up" | "down" | "auto" | Which way the columns turn. |
duration | number | 450 | Milliseconds for one turn. |
label | string | No default | What a screen reader hears instead of the formatted value. |
className | string | No default | Merged after tabular-nums. Use it for weight and color. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
The turn runs 450ms. DESIGN.md caps state changes at 320ms; the roll is listed as a sanctioned long run.
@sfinterface/numbers is pre-1.0 and pinned to an exact version, because its props and motion still change between releases.