Status label
An 8px dot that always travels with a text label, in neutral, live, success, warning, danger and info tones.
| Payment run | Status | Amount |
|---|---|---|
| Friday freight runPR-1042, 48 suppliers | Running | $612,480.00 |
| Net 30 utilitiesPR-1041, 12 suppliers | Needs approval | $38,902.15 |
| Halcyon early payPR-1040, 1 supplier | Paid | $91,377.12 |
| Orchard Street rebillPR-1039, 3 suppliers | Failed | $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. StatusDotalone 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
The Quiet Indigo Rule
Anatomy#
- Dot. 8px round, filled with the tone. Always
aria-hidden. - Pulse. Optional. A copy of the dot at 60% that scales out and fades on
animate-ping, only when motion is allowed. - Gap. 6px between dot and label (
gap-1.5). - 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.
neutral. Not started, off, skippedprimary. Live: running, on a callsuccess. Done well: paid, passedwarning. Needs attentiondanger. Broken: failed, stalledinfo. Informationalimport { 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.
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 fileimport { 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.
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#
| State | Treatment |
|---|---|
| Neutral | Faint Slate dot. Not started, off, skipped, draft, archived. |
| Primary (live) | Quiet Indigo dot. Running, on a call, active, upcoming. Usually with pulse. |
| Success | Ledger Green. Paid, passed, connected, on track. |
| Warning | Caution Amber. At risk, needs approval, invited, pending. |
| Danger | Signal Red. Failed, stalled, rejected, disconnected. |
| Info | Note Blue. Scheduled, informational. |
| Pulsing | The ring scales to 2x and fades over 1s, forever, while pulse is set. |
| Reduced motion | The 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.
StatusLabelrendersStatusDotthen its children, so anyclassNameyou pass lands on the outer span:text-xs text-muted-foregroundsets a meta-row variant.- The label never wraps (
whitespace-nowrap). Give the column room or shorten the word. StatusDotaccepts aclassNamefor alignment, such asmt-1.5to 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#
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#
| Token | Used for |
|---|---|
--subtle-foreground | Neutral dot (Faint Slate) |
--primary | Primary (live) dot |
--success | Success dot |
--warning | Warning dot |
--destructive | Danger dot |
--info | Info dot |
--foreground | Label text |
animate-ping | Pulse: scale to 2x and fade, 1s, infinite |
text-13, gap-1.5 | Body Dense label, 6px gap |
API reference#
StatusLabel
A dot and a label on one line.
Other props spread onto <span>.
| Prop | Type | Default | Description |
|---|---|---|---|
tone | "neutral" | "primary" | "success" | "warning" | "danger" | "info" | "neutral" | The state's color. primary is the live tone. |
pulse | boolean | false | Adds the ping ring, for live states only. |
childrenRequired | React.ReactNode | No default | The state in one or two words. |
className | string | No default | On the outer span. text-xs text-muted-foreground for meta rows. |
StatusDot
The 8px dot alone, always aria-hidden.
Other props spread onto <span>.
| Prop | Type | Default | Description |
|---|---|---|---|
tone | StatusTone | "neutral" | The state's color. |
pulse | boolean | false | Adds the ping ring. |
className | string | No default | Alignment, 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.