Skip to content

Progress

A 4px ink bar that shows how much of a known task is done.

Status
Beta
Level
Atom
Category
Feedback
Adoption
Not used yet
import { Progress } from "@oration/canon/components/progress";
packages/canon/src/components/progress.tsx

Import suppliers

suppliers-sept-2026.csv, 1,208 rows

Importing 1,208 suppliers
x
import { Button } from "@oration/canon/components/button";import { Progress, ProgressLabel, ProgressValue } from "@oration/canon/components/progress";import { StatusDot } from "@oration/canon/components/status-dot";import { toast } from "@oration/canon/components/toast";import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function Hero() {    const total = 1208;    const [done, setDone] = React.useState(0);    React.useEffect(() => {        if (done >= 1208) return;        const id = window.setTimeout(            () => setDone((value) => Math.min(1208, value + 97)),            180,        );        return () => window.clearTimeout(id);    }, [done]);    const finished = done >= total;    return (        <div className="flex w-full max-w-md flex-col overflow-hidden rounded-xl bg-popover shadow-lg">            <div className="flex flex-col gap-1 px-4 pt-4">                <p className="text-base leading-none font-medium text-foreground">                    Import suppliers                </p>                <p className="text-sm text-muted-foreground">                    suppliers-sept-2026.csv, 1,208 rows                </p>            </div>            <div className="flex flex-col gap-4 p-4">                <Progress value={done} max={total}>                    <ProgressLabel className="text-[13px]">                        {finished                            ? "Import finished"                            : "Importing 1,208 suppliers"}                    </ProgressLabel>                    <ProgressValue className="text-[13px]" />                </Progress>                <ul                    className={cn(                        "flex flex-col gap-1.5 text-13 transition-opacity duration-150",                        finished ? "opacity-100" : "opacity-0",                    )}                    aria-hidden={!finished}                >                    <li className="flex items-center gap-2">                        <StatusDot tone="success" />                        <span className="tabular-nums">                            1,164 suppliers created                        </span>                    </li>                    <li className="flex items-center gap-2">                        <StatusDot tone="info" />                        <span className="tabular-nums">38 updated</span>                    </li>                    <li className="flex items-center gap-2">                        <StatusDot tone="warning" />                        <span className="tabular-nums">                            6 skipped, missing a tax ID                        </span>                    </li>                </ul>            </div>            <div className="flex items-center justify-end gap-2 border-t border-border bg-muted/50 px-4 py-3">                <Button                    type="button"                    variant="outline"                    disabled={!finished}                    onClick={() => setDone(0)}                >                    Import again                </Button>                <Button                    type="button"                    disabled={!finished}                    onClick={() =>                        toast.add({                            type: "success",                            title: "Suppliers ready",                            description:                                "1,202 suppliers are in your directory.",                        })                    }                >                    Done                </Button>            </div>        </div>    );}

Usage#

Progress is a 4px Ink 65 bar on a Well Gray track that shows how much of a task with a known size is done: an import, an export, a payment run going out, a test suite running. It is built on Base UI Progress, so it is announced as a progress bar with its value, and its label and value are wired up for you. It measures work in motion; for a level against a limit, such as credits or seats, use Meter. It has no indeterminate animation, so when the size of the work is unknown, reach for a spinner instead of a bar that sits empty.

When to use

  • For a task whose total is known and that finishes in front of the person: Importing 1,208 suppliers, Sending 212 payments.
  • With a label and a value above the bar, in a dialog, sheet or settings section while the work runs.
  • In a table cell or card, with an aria-label, for runs that are still going: a payment run's sent count, a campaign's dialed count.

When not to use

  • For a quantity measured against a limit or target, such as credits used or occupancy. Use Meter
  • For work of unknown length. The bar has no indeterminate animation. Use Spinner
  • For an AI model thinking or generating. Use AI loader
  • For steps a person moves through, such as an onboarding flow. Use Stepper
  • For content that is loading into a layout. Use Skeleton

The Ink Fill Rule

Magnitudes are drawn in ink steps on a Well Gray track, and progress is Ink 65. The fill is never indigo, and it doesn't turn green on completion: finishing isn't a verdict, the words say it.

The Label-Beside-Color Rule

A bar alone doesn't say what is progressing or how far. Pair it with a label (Importing 1,208 suppliers) and a value (42% or 89 of 212).

Anatomy#

Exporting invoices
x
  1. Label. ProgressLabel, 14px medium, linked to the bar with aria-labelledby. Says what is running.
  2. Value. ProgressValue, pushed right with ml-auto, 14px Slate Meta in tabular figures. A formatted percent by default.
  3. Track. A 4px round Well Gray track at full width, wrapping onto its own line under the label and value.
  4. Indicator. Ink 65 (bg-foreground/65), its width set by Base UI as a percentage and eased over 300ms.

Examples#

Label and value

ProgressLabel says what is running and ProgressValue shows a formatted percent on the right. Both default to 14px; pass text-[13px] in dense surfaces.

Exporting September invoices
x
Exporting September invoices
x
Export finished
x
import { Progress, ProgressLabel, ProgressValue } from "@oration/canon/components/progress";export function LabelAndValue() {    const rows = [        { label: "Exporting September invoices", value: 0 },        { label: "Exporting September invoices", value: 42 },        { label: "Export finished", value: 100 },    ];    return (        <div className="flex w-full max-w-sm flex-col gap-6">            {rows.map((row) => (                <Progress key={row.value} value={row.value}>                    <ProgressLabel className="text-[13px]">                        {row.label}                    </ProgressLabel>                    <ProgressValue className="text-[13px]" />                </Progress>            ))}        </div>    );}

Counting real units

Set max to the real total and give ProgressValue a function to show 89 of 212. getAriaValueText makes screen readers hear the same words.

Sending Friday freight run
x
import { Button } from "@oration/canon/components/button";import { Progress, ProgressLabel, ProgressValue } from "@oration/canon/components/progress";import * as React from "react";export function CountValue() {    const total = 212;    const [sent, setSent] = React.useState(89);    return (        <div className="flex w-full max-w-sm flex-col gap-4">            <Progress                value={sent}                max={total}                getAriaValueText={(_formatted, value) =>                    `${value ?? 0} of ${total} payments sent`                }            >                <ProgressLabel className="text-[13px]">                    {sent >= total                        ? "212 payments sent"                        : "Sending Friday freight run"}                </ProgressLabel>                <ProgressValue className="text-[13px]">                    {(_formatted, value) => `${value ?? 0} of ${total}`}                </ProgressValue>            </Progress>            <Button                type="button"                variant="outline"                size="sm"                className="self-start"                disabled={sent >= total}                onClick={() => setSent((value) => Math.min(total, value + 41))}            >                Send next batch            </Button>        </div>    );}

In a table cell

A 96px bar with the count beside it for runs that are still sending. With no visible label, the root takes an aria-label that names the run.

Payment runs sending today
Payment runSent
Friday freight run
x
89 of 212
Net 30 utilities
x
12 of 12
Halcyon early pay
x
0 of 1
import { Progress } from "@oration/canon/components/progress";export function InATable() {    const runs = [        { id: "PR-1042", name: "Friday freight run", sent: 89, total: 212 },        { id: "PR-1043", name: "Net 30 utilities", sent: 12, total: 12 },        { id: "PR-1044", name: "Halcyon early pay", sent: 0, total: 1 },    ];    return (        <div className="w-full max-w-xl overflow-hidden rounded-xl bg-card shadow-border">            <table className="w-full text-13">                <caption className="sr-only">                    Payment runs sending today                </caption>                <thead>                    <tr className="h-8 border-b border-border text-left text-muted-foreground">                        <th scope="col" className="px-3 font-medium">                            Payment run                        </th>                        <th scope="col" className="px-3 font-medium">                            Sent                        </th>                    </tr>                </thead>                <tbody>                    {runs.map((run) => (                        <tr                            key={run.id}                            className="h-9 border-b border-border last:border-b-0"                        >                            <td className="px-3 font-medium text-foreground">                                {run.name}                            </td>                            <td className="px-3">                                <span className="flex items-center gap-2.5">                                    <Progress                                        value={run.sent}                                        max={run.total}                                        aria-label={`${run.name}, payments sent`}                                        className="w-24"                                    />                                    <span className="text-xs text-muted-foreground tabular-nums">                                        {run.sent} of {run.total}                                    </span>                                </span>                            </td>                        </tr>                    ))}                </tbody>            </table>        </div>    );}

States#

Empty
x
value=0
Progressing
x
value=42
Complete
x
value=100
Indeterminate
x
value=null
import { Progress } from "@oration/canon/components/progress";export function StatesRow() {    const states = [        { label: "Empty", value: 0 },        { label: "Progressing", value: 42 },        { label: "Complete", value: 100 },        { label: "Indeterminate", value: null },    ];    return (        <div className="grid w-full grid-cols-2 gap-6 sm:grid-cols-4">            {states.map((state) => (                <div key={state.label} className="flex flex-col gap-2">                    <span className="text-xs text-muted-foreground">                        {state.label}                    </span>                    <Progress value={state.value} aria-label={state.label} />                    <code className="font-mono text-xs text-muted-foreground">                        value={state.value === null ? "null" : state.value}                    </code>                </div>            ))}        </div>    );}
States
StateTreatment
Emptyvalue={0}. The track alone, with the value at 0%.
Progressingdata-progressing on every part. The fill grows over 300ms on the house ease-out each time the value changes.
Completedata-complete once value reaches max. The fill stays Ink 65; change the label to say it finished.
Indeterminatevalue={null} sets data-indeterminate and drops aria-valuenow, but nothing moves: the indicator collapses and the track sits empty.

Behavior#

  • Progress renders its children first, then its own track and indicator, in a wrapping flex row. Put ProgressLabel and ProgressValue inside it; the track drops to the next line at full width.
  • value runs from min (0) to max (100). Base UI clamps it and writes aria-valuenow, aria-valuemin and aria-valuemax.
  • ProgressValue shows the value through Intl.NumberFormat: a percent by default, or whatever format and locale say. Pass a function as its child to write your own text, such as 89 of 212 invoices.
  • getAriaValueText sets what screen readers hear for the value, so the spoken and visible text can match.
  • The width transition is 300ms on --ease-out (cubic-bezier(0.23, 1, 0.32, 1)). Updates faster than that read as one smooth fill.
  • State attributes (data-progressing, data-complete, data-indeterminate) are on the root, track, indicator, label and value, for styling from outside.

Do and don't#

Importing suppliers
x
Do. Draw the fill in Ink 65, the same ink as every other magnitude.
Importing suppliers64%
Don't. Fill it indigo or green. Indigo means selected or live, and green says something passed.
Sending 212 payments
x
Do. Say what is running and how far along it is, above the bar.
x
Don't. Show a bare bar. Nobody knows what it measures or whether 60% is good news.

Content#

  • The label names the work in progress with a count: Importing 1,208 suppliers, Sending 212 payments.
  • When it finishes, the label says so in the past tense: Import finished, 212 payments sent.
  • Prefer counts to percents when the unit matters: 89 of 212 invoices tells more than 42%.
  • Don't promise time you can't measure. Leave out About 2 minutes left unless the estimate is real.

Accessibility#

  • Base UI renders role="progressbar" with aria-valuenow, aria-valuemin, aria-valuemax and aria-valuetext, and links ProgressLabel with aria-labelledby.
  • Without a visible label, give the root an aria-label: Friday freight run, payments sent.
  • Screen readers don't announce every change of a progress bar. Announce the start and the finish in a role="status" region or a toast.
  • The width transition is short and only moves the fill; it isn't disabled under reduced motion.

Design tokens#

Design tokens
TokenUsed for
--mutedTrack (Well Gray)
--foreground at 65%Indicator (Ink 65)
--muted-foregroundValue text
--ease-out300ms width transition
rounded-full, h-14px round track

API reference#

Progress

The root. Renders its children, then the track and indicator.

Other props spread onto Base UI Progress.Root (<div>).

Props of Progress
PropTypeDefaultDescription
valueRequirednumber | nullNo defaultThe current value. null is indeterminate.
minnumber0The value at an empty bar.
maxnumber100The value at a full bar. Use the real total, such as 212.
formatIntl.NumberFormatOptionsNo defaultHow ProgressValue and the default value text format the value.
localeIntl.LocalesArgumentNo defaultLocale for formatting. Defaults to the runtime locale.
getAriaValueText(formattedValue: string, value: number | null) => stringNo defaultSets the spoken value.
childrenReact.ReactNodeNo defaultProgressLabel and ProgressValue.
classNamestringNo defaultOn the root, merged after flex flex-wrap gap-3.

ProgressLabel

What is progressing, linked to the bar.

Other props spread onto Base UI Progress.Label (<span>).

Props of ProgressLabel
PropTypeDefaultDescription
classNamestringNo defaultMerged after text-sm font-medium. Use text-[13px] in dense layouts.

ProgressValue

The formatted value, pushed to the right.

Other props spread onto Base UI Progress.Value (<span>).

Props of ProgressValue
PropTypeDefaultDescription
children(formattedValue: string | null, value: number | null) => React.ReactNodeNo defaultWrite your own value text. Defaults to the formatted value.
classNamestringNo defaultMerged after ml-auto text-sm text-muted-foreground tabular-nums.

ProgressTrack

The 4px Well Gray track. Progress renders one already; export it only for custom layouts.

Other props spread onto Base UI Progress.Track (<div>).

Props of ProgressTrack
PropTypeDefaultDescription
classNamestringNo defaultMerged last.

ProgressIndicator

The Ink 65 fill inside the track.

Other props spread onto Base UI Progress.Indicator (<div>).

Props of ProgressIndicator
PropTypeDefaultDescription
classNamestringNo defaultMerged last.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

Indeterminate draws nothing. With value={null} the indicator has no width and the track sits empty, with no animation.

Progress always appends its own track, so ProgressTrack and ProgressIndicator can't be used to rearrange the parts inside it without rendering two tracks.

Label and value default to 14px, while DESIGN.md sets dense UI at 13px. The one product call site overrides both with text-13, which cn from the cn package keeps alongside text-sm, so the rendered size depends on stylesheet order.

It is used only in the import and export settings. The evaluation live run panel and the knowledge document meta draw their own role="progressbar" bars by hand.