Skip to content

Animated number

A tabular figure that rolls to its new value when it changes.

Status
Stable
Level
Atom
Category
Data display
Adoption
Not used yet
import { AnimatedNumber } from "@oration/canon/components/animated-number";
packages/canon/src/components/animated-number.tsx

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 format for 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

Every number that changes or gets compared is set in tabular figures. Animated number sets tabular-nums for you, so digits keep their width while they turn and columns stay aligned.

Motion grammar

Movement explains a change; it never decorates. Numbers animate only when their value changes in front of the person, never on load, and reduced motion swaps them instantly.

The Roll Rule

Figures use the default 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#

+$12,480.50 net
  1. 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.
  2. 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.
  3. Symbols. Currency signs, group separators, decimal points and percent signs from format. They don't turn; they enter and leave as the width changes.
  4. 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.

Supplier cohort

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.

Invoices captured today1,182
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>    );}
States
StateTreatment
First renderThe formatted value is in the server markup and drawn statically. Nothing animates on mount.
IncreasingChanged 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.
DecreasingColumns turn the other way, so direction reads without an arrow.
Fast turnsA 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 columnsA digit or separator that appears or disappears (999 to 1,000) comes and goes over 240ms.
Reduced motionWith 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 through className and 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 its arc layer sits below every utility.
  • locale defaults to en-US, so grouping is 1,184,250. Pass another locale, such as "en-IN" or "ar-EG", to get its grouping and digits.
  • format takes any Intl.NumberFormat options: { 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 duration only with a reason.
  • trend is "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, title and aria-*, is spread onto the root span, and ref reaches it.

Do and don't#

Do. Animate a figure that changes because of the person's action, such as the counts on view tabs after a bulk update.
Don't. Swap every figure in a table or card for an animated number. Values that never change add thirty cells per digit and nothing else.
$1,184,250.50-$1,200.00
Do. Use format for money and percents, so decimals, signs and grouping stay correct as the value turns.
$1,184,250.5$-1,200
Don't. Build the display by hand with 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 prefix for a sign on a delta (+12) and suffix for a unit that belongs to the figure (42% from format is 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 label when 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#

Design tokens
TokenUsed for
tabular-numsSet on every animated number
cubic-bezier(0.32, 0.72, 0, 1)The house drawer curve, 450ms per turn
--sfi-numbers-exit, 240msColumns and symbols leaving
--sfi-numbers-cell, 1.4emThe pitch the digits are stacked at
currentColor, inherited fontColor, 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..

Props of AnimatedNumber
PropTypeDefaultDescription
valueRequirednumberNo defaultThe number to show. Changing it starts the turn.
formatIntl.NumberFormatOptionsNo defaultHow the number is formatted, such as currency or percent.
localestring | string[]"en-US"Passed to Intl.NumberFormat.
prefixReactNodeNo defaultBefore the digits, read with the value.
suffixReactNodeNo defaultAfter the digits, read with the value.
trend"auto" | "up" | "down""auto"Which way the columns turn.
durationnumber450Milliseconds for one turn.
labelstringNo defaultWhat a screen reader hears instead of the formatted value.
classNamestringNo defaultMerged 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.