Skip to content

Status label

An 8px dot that always travels with a text label, in neutral, live, success, warning, danger and info tones.

Status
Stable
Level
Atom
Category
Feedback
Adoption
Not used yet
import { StatusDot } from "@oration/canon/components/status-dot";
packages/canon/src/components/status-dot.tsx
Payment runs this week
Payment runStatusAmount
Friday freight runPR-1042, 48 suppliersRunning$612,480.00
Net 30 utilitiesPR-1041, 12 suppliersNeeds approval$38,902.15
Halcyon early payPR-1040, 1 supplierPaid$91,377.12
Orchard Street rebillPR-1039, 3 suppliersFailed$4,120.00
import { StatusLabel } from "@oration/canon/components/status-dot";export function Hero() {    const runs = [        {            id: "PR-1042",            name: "Friday freight run",            suppliers: 48,            amount: "$612,480.00",            status: { tone: "primary", label: "Running", pulse: true },        },        {            id: "PR-1041",            name: "Net 30 utilities",            suppliers: 12,            amount: "$38,902.15",            status: { tone: "warning", label: "Needs approval", pulse: false },        },        {            id: "PR-1040",            name: "Halcyon early pay",            suppliers: 1,            amount: "$91,377.12",            status: { tone: "success", label: "Paid", pulse: false },        },        {            id: "PR-1039",            name: "Orchard Street rebill",            suppliers: 3,            amount: "$4,120.00",            status: { tone: "danger", label: "Failed", pulse: false },        },    ] as const;    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 this week</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">                            Status                        </th>                        <th scope="col" className="px-3 text-right font-medium">                            Amount                        </th>                    </tr>                </thead>                <tbody>                    {runs.map((run) => (                        <tr                            key={run.id}                            className="h-11 border-b border-border last:border-b-0"                        >                            <td className="px-3">                                <span className="flex flex-col">                                    <span className="font-medium text-foreground">                                        {run.name}                                    </span>                                    <span className="text-xs text-muted-foreground tabular-nums">                                        {run.id}, {run.suppliers}{" "}                                        {run.suppliers === 1                                            ? "supplier"                                            : "suppliers"}                                    </span>                                </span>                            </td>                            <td className="px-3">                                <StatusLabel                                    tone={run.status.tone}                                    pulse={run.status.pulse}                                >                                    {run.status.label}                                </StatusLabel>                            </td>                            <td className="px-3 text-right font-medium tabular-nums">                                {run.amount}                            </td>                        </tr>                    ))}                </tbody>            </table>        </div>    );}

Usage#

Status label is an 8px dot and a short word that together say what state something is in: Running, Paid, At risk, Failed. It is how Oration shows status in table cells, record headers and list rows, and it is the only sanctioned way to put a status color on screen. StatusDot is the dot alone, for layouts where the words sit in their own element. The mistake to avoid is the dot without the words: color alone never carries status.

When to use

  • For the state of a record or process in a table cell: payment runs, test runs, agents, imports, integrations.
  • Beside a record's name in its header, for the one state that matters most: W-9 on file, Bank change pending.
  • For live states that carry a label, in the primary tone with pulse: Running, On a call.
  • StatusDot alone when the label is richer than one line, such as a checklist item with a title and detail, and the words sit right beside it.

When not to use

  • For a select-option value the person picks, such as a stage, tier or lifecycle. Use Tag
  • For a count or a short label attached to a control. Use Badge
  • For a message that needs a sentence and maybe an action. Use Alert
  • For an AI agent's identity with its live ring. Use Agent avatar
  • For how far along something is. Show the amount done. Use Progress

The Label-Beside-Color Rule

Status is never color alone. A dot, tint or ring always travels with a text label (On track, At risk, Running, Passed), so the state reads in grayscale.

The Quiet Indigo Rule

The primary tone is indigo, and indigo is spent only on live state that carries a label: running, active, on a call, upcoming. Done, paid and passed are success, not primary.

Anatomy#

Running
  1. Dot. 8px round, filled with the tone. Always aria-hidden.
  2. Pulse. Optional. A copy of the dot at 60% that scales out and fades on animate-ping, only when motion is allowed.
  3. Gap. 6px between dot and label (gap-1.5).
  4. Label. One or two words in Graphite Ink, meant to be Body Dense (13px), on one line (whitespace-nowrap).

Examples#

Tones

Six tones, each for a kind of state rather than a color preference. primary is the live tone and the only indigo one.

Draftneutral. Not started, off, skipped
Runningprimary. Live: running, on a call
Paidsuccess. Done well: paid, passed
At riskwarning. Needs attention
Faileddanger. Broken: failed, stalled
Scheduledinfo. Informational
import { StatusLabel } from "@oration/canon/components/status-dot";export function Tones() {    const tones = [        { tone: "neutral", label: "Draft", use: "Not started, off, skipped" },        { tone: "primary", label: "Running", use: "Live: running, on a call" },        { tone: "success", label: "Paid", use: "Done well: paid, passed" },        { tone: "warning", label: "At risk", use: "Needs attention" },        { tone: "danger", label: "Failed", use: "Broken: failed, stalled" },        { tone: "info", label: "Scheduled", use: "Informational" },    ] as const;    return (        <div className="grid w-full max-w-2xl grid-cols-2 gap-x-8 gap-y-4 text-13 sm:grid-cols-3">            {tones.map((item) => (                <div key={item.tone} className="flex flex-col gap-1">                    <StatusLabel tone={item.tone}>{item.label}</StatusLabel>                    <span className="pl-3.5 text-xs text-muted-foreground">                        <code className="font-mono">{item.tone}</code>.{" "}                        {item.use}                    </span>                </div>            ))}        </div>    );}

Pulse

pulse adds a ping ring for live states. End the call to see the same label settle.

On a callRunningUpcoming
import { Button } from "@oration/canon/components/button";import { StatusLabel } from "@oration/canon/components/status-dot";import * as React from "react";export function Pulse() {    const [live, setLive] = React.useState(true);    return (        <div className="flex flex-col items-center gap-5 text-13">            <div className="flex items-center gap-6">                <StatusLabel tone="primary" pulse={live}>                    {live ? "On a call" : "Idle"}                </StatusLabel>                <StatusLabel tone="primary" pulse>                    Running                </StatusLabel>                <StatusLabel tone="primary">Upcoming</StatusLabel>            </div>            <Button                type="button"                variant="outline"                size="sm"                aria-pressed={live}                onClick={() => setLive((value) => !value)}            >                {live ? "End call" : "Start call"}            </Button>        </div>    );}

In a record header

One status beside the record name, and quieter meta statuses in the line below with text-xs text-muted-foreground.

Northwind Freight

W-9 on file
Supplier since March 2023142 invoices this yearBank change pending
import { StatusLabel } from "@oration/canon/components/status-dot";export function RecordHeader() {    return (        <div className="flex w-full max-w-xl flex-col gap-3">            <div className="flex flex-wrap items-center gap-x-3 gap-y-1">                <h3 className="text-xl font-semibold tracking-[-0.015em] text-foreground">                    Northwind Freight                </h3>                <StatusLabel tone="success" className="text-[13px]">                    W-9 on file                </StatusLabel>            </div>            <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">                <span>Supplier since March 2023</span>                <span className="tabular-nums">142 invoices this year</span>                <StatusLabel                    tone="warning"                    className="text-xs text-muted-foreground"                >                    Bank change pending                </StatusLabel>            </div>        </div>    );}

Dot with its own text

StatusDot alone for items whose words need two lines. The title right beside it still names the state.

  • W-9 receivedSigned Sep 14
  • Bank account verifiedMicro-deposits matched
  • Remittance email bouncedap@northwindfreight.com
  • Insurance certificateNot requested yet
import { StatusDot } from "@oration/canon/components/status-dot";export function DotOnly() {    const checks = [        { tone: "success", title: "W-9 received", detail: "Signed Sep 14" },        {            tone: "success",            title: "Bank account verified",            detail: "Micro-deposits matched",        },        {            tone: "warning",            title: "Remittance email bounced",            detail: "ap@northwindfreight.com",        },        {            tone: "neutral",            title: "Insurance certificate",            detail: "Not requested yet",        },    ] as const;    return (        <ul className="flex w-full max-w-sm flex-col gap-3">            {checks.map((check) => (                <li key={check.title} className="flex gap-2.5">                    <StatusDot tone={check.tone} className="mt-1.5" />                    <span className="flex min-w-0 flex-col">                        <span className="text-13 font-medium text-foreground">                            {check.title}                        </span>                        <span className="text-xs text-muted-foreground">                            {check.detail}                        </span>                    </span>                </li>            ))}        </ul>    );}

Announcing a change

When a status changes in front of someone, wrap it in role="status" so the new word is read out. Run the test to move from Not run yet to Running to Passed.

Payment status lineNot run yet
import { Button } from "@oration/canon/components/button";import { StatusLabel } from "@oration/canon/components/status-dot";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function LiveUpdate() {    const [status, setStatus] = React.useState<"queued" | "running" | "passed">(        "queued",    );    React.useEffect(() => {        if (status !== "running") return;        const id = window.setTimeout(() => {            setStatus("passed");            toast.add({                type: "success",                title: "Test run passed",                description: "Payment status line answered all 12 test calls.",            });        }, 2000);        return () => window.clearTimeout(id);    }, [status]);    const meta = {        queued: { tone: "neutral", label: "Not run yet", pulse: false },        running: { tone: "primary", label: "Running", pulse: true },        passed: { tone: "success", label: "Passed", pulse: false },    } as const;    const current = meta[status];    return (        <div className="flex w-full max-w-sm items-center justify-between gap-3 rounded-xl bg-card px-4 py-3 shadow-border">            <div className="flex min-w-0 flex-col gap-0.5">                <span className="text-13 font-medium text-foreground">                    Payment status line                </span>                <span role="status" className="text-13">                    <StatusLabel tone={current.tone} pulse={current.pulse}>                        {current.label}                    </StatusLabel>                </span>            </div>            <Button                type="button"                variant="outline"                size="sm"                disabled={status === "running"}                onClick={() => setStatus("running")}            >                {status === "passed" ? "Run again" : "Run test"}            </Button>        </div>    );}

States#

States
StateTreatment
NeutralFaint Slate dot. Not started, off, skipped, draft, archived.
Primary (live)Quiet Indigo dot. Running, on a call, active, upcoming. Usually with pulse.
SuccessLedger Green. Paid, passed, connected, on track.
WarningCaution Amber. At risk, needs approval, invited, pending.
DangerSignal Red. Failed, stalled, rejected, disconnected.
InfoNote Blue. Scheduled, informational.
PulsingThe ring scales to 2x and fades over 1s, forever, while pulse is set.
Reduced motionThe ring is behind motion-safe:, so it never renders its animation. The dot and label stay.

Behavior#

  • Both components are plain spans with no state. The parent picks the tone and label from the record's data.
  • StatusLabel renders StatusDot then its children, so any className you pass lands on the outer span: text-xs text-muted-foreground sets a meta-row variant.
  • The label never wraps (whitespace-nowrap). Give the column room or shorten the word.
  • StatusDot accepts a className for alignment, such as mt-1.5 to sit on the first line of a two-line item.
  • When a status changes while the person watches, put the label inside a role="status" region so the new word is announced.

Do and don't#

PaidNeeds approvalFailed
Do. Pair every dot with the word for the state.
PR-1040PR-1041PR-1039
Don't. Show a column of colored dots and expect people to learn the colors. In grayscale, or to a screen reader, they say nothing.
RunningPaidDraft
Do. Pulse only live states, such as the run that is running now.
RunningPaidFailed
Don't. Pulse every status. When everything moves, nothing reads as live.
FailedHalcyon's bank rejected the ACH file.
Do. Keep the label to the state and put the reason beside it.
The payment run failed because the bank rejected it
Don't. Write a sentence in the label. It won't wrap, and a status should scan in a column.

Content#

  • One or two words, sentence case, no punctuation: Paid, Needs approval, On a call.
  • Name the state, not the action: Paid, not Pay; Failed, not Retry.
  • Use the present participle for live states: Running, Syncing, Dialing.
  • Keep one word per state across the product. If it is Failed in the payment runs table, it is Failed in the run's header and in the toast.
  • Put reasons and counts in the next element, not in the label: Failed with Halcyon's bank rejected the ACH file beneath.

Accessibility#

  • The dot is aria-hidden. The label text is what assistive technology reads, so it must name the state on its own.
  • Because the words carry the state, the dot is decorative and doesn't need 3:1 contrast. Amber on white is well under that, which is one more reason the label is required.
  • For status that changes live, wrap it in a role="status" region that exists before the change.
  • Pulse is off under prefers-reduced-motion (motion-safe:animate-ping).
  • In tables, the column header (Status) gives the label its context; don't repeat Status: in every cell.

Design tokens#

Design tokens
TokenUsed for
--subtle-foregroundNeutral dot (Faint Slate)
--primaryPrimary (live) dot
--successSuccess dot
--warningWarning dot
--destructiveDanger dot
--infoInfo dot
--foregroundLabel text
animate-pingPulse: scale to 2x and fade, 1s, infinite
text-13, gap-1.5Body Dense label, 6px gap

API reference#

StatusLabel

A dot and a label on one line.

Other props spread onto <span>.

Props of StatusLabel
PropTypeDefaultDescription
tone"neutral" | "primary" | "success" | "warning" | "danger" | "info""neutral"The state's color. primary is the live tone.
pulsebooleanfalseAdds the ping ring, for live states only.
childrenRequiredReact.ReactNodeNo defaultThe state in one or two words.
classNamestringNo defaultOn the outer span. text-xs text-muted-foreground for meta rows.

StatusDot

The 8px dot alone, always aria-hidden.

Other props spread onto <span>.

Props of StatusDot
PropTypeDefaultDescription
toneStatusTone"neutral"The state's color.
pulsebooleanfalseAdds the ping ring.
classNamestringNo defaultAlignment, such as mt-1.5.

StatusTone

Type: "neutral" | "primary" | "success" | "warning" | "danger" | "info". Use it to type tone maps such as Record<RunStatus, StatusTone>.

No props of its own.

Known gaps#

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

StatusLabel merges its classes with cn from the cn package, which reads text-13 as a text color and drops it in favor of text-foreground. The label renders at its parent's size, not 13px. It looks right inside 13px table cells; elsewhere pass text-[13px], as the record header example does.

The live tone is named primary in code, while DESIGN.md and the registry call it live.

The pulse is Tailwind's default animate-ping (1s, cubic-bezier(0, 0, 0.2, 1)), not a Canon motion token.

The flows table pulses its success tone (pulse={status.tone === "success"}). Pulse belongs to the primary live tone.