Composites
Status Pill
A status chip that renders whatever status table your app declares. The package ships the type, the six tone recipes and four helpers; the statuses, their labels and their sort order stay in your product — because Tasks has todo / doing / done, CRM has lead / won / lost, and no shared enum survives that.
Declare a status table
Everything on this page starts from one array of StatusConfig. Each entry pairs the value as your data stores it with a label, an optional abbreviation, an optional sort weight, and a tone spread in from STATUS_TONES. Pass the whole array to every pill — StatusPill keeps no registry, which is what lets two features in the same app use different status vocabularies.
Three variants
soft is the default tinted chip. outline drops the fill and keeps a border in the same ink colour — use it on an already-tinted surface where another wash would muddy things. dot is the quietest: a 6px dot in the status colour with the label in normal foreground text, for dense lists where a row of coloured blocks would be louder than the data.
softDraftSubmittedIn reviewApprovedRejectedEscalatedoutlineDraftSubmittedIn reviewApprovedRejectedEscalateddotDraftSubmittedIn reviewApprovedRejectedEscalatedThe dot is drawn with the config's text class and backgroundColor: currentColor, so it always matches the ink step exactly — one colour to keep in sync instead of two.
Every tone in STATUS_TONES
Six recipes, each three static class strings. Spread one into a config rather than copying the classes — and never assemble them at runtime (`bg-${tone}/15`): Tailwind scans source text, so a generated class never reaches your app's CSS.
| Tone | soft | outline | dot | Classes |
|---|---|---|---|---|
| neutral | neutral | neutral | neutral | bg-muted text-muted-foreground border-muted-foreground |
| primary | primary | primary | primary | bg-primary/20 text-primary-ink border-primary-ink |
| success | success | success | success | bg-success/15 text-success-ink border-success-ink |
| warning | warning | warning | warning | bg-warning/20 text-warning-ink border-warning-ink |
| destructive | destructive | destructive | destructive | bg-destructive/15 text-destructive-ink border-destructive-ink |
| info | info | info | info | bg-info/15 text-info-ink border-info-ink |
warningandprimaryuse/20where the others use/15. Yellow needs the extra step to separate from the page at all: at/15theprimaryfill reads only 1.10:1 against the page, and/20lifts it to 1.13:1 — level withwarning, the other tone that needs the extra step.primaryfollows--primary, not thegold-*scale. The old hard-coded gold classes had adequate contrast but the wrong role: the raw scale has no dark variant, so it neededdark:overrides, and an app that re-branded--primarystill got a yellow “primary” chip.neutralis the fallback tone: any value missing fromconfigsrenders asbg-muted+text-muted-foreground.- A tone is only three strings, so custom ones are trivial — keep them to background-role tokens for
bgColorand ink-role tokens for the other two.
Short labels
short switches the pill to shortLabel where the config supplies one, and quietly keeps label where it does not — so adding abbreviations to half a table is safe.
Abbreviations are opaque out of context. In a narrow table column, pair the short pill with a title attribute — it is forwarded straight to the underlying <span>.
Unknown and missing values
Status enums drift: the backend adds a state, or an old record still carries one that was retired. The pill refuses to swallow either. An unmatched value renders in the neutral tone showing the raw value, so the row still carries its information and the drift is visible instead of invisible.
The helpers
status-config is a plain module, not a component — the same four functions work in a server action, a table cell, or a PDF generator. Pick a value below and watch each one respond; the last button is a status that is not in the table.
- getStatusConfig
- { value: 'review', … }
- getStatusLabel
- 'In review'
- getStatusLabel · short
- 'REV'
- getStatusClasses
- 'bg-warning/20 text-warning-ink'
| Prop | Type | Default | Description |
|---|---|---|---|
getStatusConfig | (configs, value) => StatusConfig<T> | undefined | — | Finds the entry by value. Returns undefined for null, undefined, or a value not in the table. |
getStatusClasses | (configs, value) => string | — | Returns `${bgColor} ${textColor}` for use on any element. Returns an empty string when nothing matches, so supply your own fallback. |
getStatusLabel | (configs, value, options?) => string | — | Display text. options.short prefers shortLabel. An unmatched value is returned as-is rather than lost. |
sortByStatusPriority | (configs) => StatusConfig<T>[] | — | Copies and sorts by priority ascending. Entries without a priority go last, in declaration order. Never mutates the input. |
Note the two different miss behaviours, and that both are deliberate: getStatusLabel returns the unknown value so no data is lost on screen, while getStatusClasses returns an empty string so you decide the fallback rather than being handed a colour that means something.
sortByStatusPriority
priority is the answer to “which status should the user see first”, kept next to the label instead of scattered across every list that needs it. sortByStatusPriority returns a copy ordered by it — escalations first, drafts last — and entries with no priority sink to the end keeping their declaration order, so a half-annotated table still sorts sensibly.
It sorts the config table, not your rows. To order rows, use its result as a rank lookup — the demo does exactly that. The legend above the list stays in priority order either way.
RQ-1042Warehouse extension — Hai PhongApprovedRQ-1043Fuel surcharge revisionDraftRQ-1044Container fleet write-offEscalatedRQ-1045Overtime budget, Q3SubmittedRQ-1046Customs broker contract renewalIn reviewRQ-1047Third-party haulage ratesRejectedRQ-1048Depot access badgesDraft
rowClassName — a field with a trap
StatusConfig carries an optional rowClassName so the status table can also say how a whole row in that state should look. StatusPill never reads it — DataTable does, through its own rowClassName callback. There is one thing you must not put in it. It needs a full table to show anything, so it is code only here — the live version lives on the Data Table page.
// rowClassName lives on the config so the status table stays the single place
// that knows what "overdue" looks like. StatusPill ignores it — DataTable reads it.
const TASK_STATUSES: StatusConfig<TaskStatus>[] = [
{ value: 'overdue', label: 'Overdue', priority: 1,
rowClassName: 'outline outline-destructive-ink -outline-offset-1',
...STATUS_TONES.destructive },
]
<DataTable
rows={rows}
columns={columns}
getRowId={(row) => row.id}
rowClassName={(row) => getStatusConfig(TASK_STATUSES, row.status)?.rowClassName}
/>
// ✗ Do NOT put a translucent background here when the table is striped or has a
// pinned column: a pinned cell takes its background from the row with
// bg-inherit, so it inherits the alpha and turns see-through — and a flat
// background class loses the specificity fight with the stripe on even rows.
// rowClassName: 'bg-destructive/5'A translucent background here breaks in two ways at once. A pinned column takes its fill from the row with bg-inherit, so it inherits the alpha and the scrolling columns show through it; and on a striped table a flat background class loses to the stripe on alternating rows and disappears entirely. Use an outline — which is what DataTable itself uses for the active row — or say it with the pill and the text colour.
StatusPill props
| Prop | Type | Default | Description |
|---|---|---|---|
configsrequired | readonly StatusConfig<T>[] | — | Your app's whole status table. Passed on every pill — the component holds no registry of its own. |
valuerequired | T | null | undefined | — | Which status to render. A value missing from configs falls back to neutral rather than rendering nothing. |
variant | 'soft' | 'outline' | 'dot' | 'soft' | soft is a tinted fill, outline is border plus text on a transparent fill, dot is a coloured dot next to plain foreground text. |
short | boolean | false | Use shortLabel when the config has one; falls back to label when it does not. |
fallbackLabel | string | — | Text to show when value is not in configs. Without it the raw value is shown, or an em dash for null/undefined. |
className | string | — | Extra classes, merged after the tone classes — so anything here wins. |
...rest | Omit<ComponentProps<'span'>, 'children'> | — | Native span props are forwarded (title, data-*, aria-*). children is deliberately excluded: the text comes from configs. |
StatusConfig fields
StatusConfig<T> extends StatusTone, which is why spreading a tone into it type-checks.
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | T | — | The status key as your data stores it. Matched by strict equality. |
labelrequired | string | — | Human text. There is no labels prop here — this table is where your strings live, and it is yours to translate. |
shortLabel | string | — | Abbreviation for narrow columns and chips. Used only when short is set. |
priority | number | — | Sort weight, lower first. Read by sortByStatusPriority; configs without one sink to the end in declaration order. |
bgColorrequired | string | — | Tint class for the soft variant, e.g. bg-success/15. From StatusTone. |
textColorrequired | string | — | Text class — must be an ink token (text-success-ink), never the background token. From StatusTone. |
borderColor | string | — | Border class for the outline variant, e.g. border-success-ink. Optional, but omit it and outline pills fall back to border-muted-foreground. |
rowClassName | string | — | Classes for a whole table row in this status. StatusPill ignores it; DataTable reads it through its own rowClassName callback. |
StatusTone fields
| Prop | Type | Default | Description |
|---|---|---|---|
bgColorrequired | string | — | Background tint class. A background-role token, solid or at /10–/25. |
textColorrequired | string | — | Foreground class. Must be the ink step: the text sits on a tint of its own colour and owes AA 4.5:1. |
borderColor | string | — | Border class for the outline variant. Same ink step as textColor, at full strength — no alpha. |
Why every tone uses the ink step
--success, --warning, --info and --destructive are background tokens. Used as text colours they fail in light mode in all three variants, measured against the tint they actually sit on:
| Wrong | Measured |
|---|---|
| text-success on bg-success/15 | 2.69:1 — below even the 3:1 non-text floor |
| text-info on bg-info/15 | 3.44:1 |
| text-destructive on bg-destructive/15 | 4.06:1 |
| text-success / text-info on the page (outline, dot) | 3.13:1 / 4.14:1 |
Routing through --x-ink clears AA in both themes without a single dark: override, because --x-ink points back at --x inside .dark. That holds for every tone that has an ink step — which is all of them except neutral, marked below:
| Tone | soft (on its own tint) | outline · dot (on the page) |
|---|---|---|
| success | 5.60:1 L · 7.44:1 D | 6.52:1 L · 10.85:1 D |
| info | 5.54:1 L · 7.12:1 D | 6.68:1 L · 10.16:1 D |
| destructive | 5.76:1 L · 5.71:1 D | 7.15:1 L · 6.84:1 D |
| warning | 5.93:1 L · 7.19:1 D | 6.51:1 L · 12.35:1 D |
| neutral ✗ | 3.58:1 L · 4.21:1 D | 3.83:1 L · 4.94:1 D |
| primary | 5.77:1 L · 7.26:1 D | 6.51:1 L · 12.67:1 D |
neutral is the one row that does not clear AA — and it is the only tone not built from an ink token. It reuses bg-muted + text-muted-foreground, and --muted-foreground on --muted is a known, deliberately accepted shortfall of the default palette — the package's own stylesheet lists it in its “below threshold, accepted” table. Switching the user to data-contrast="high" lifts the same pair to 4.89:1 light and 5.23:1 on the page. So use a coloured tone for anything a user must read at a glance, and treat neutral as the deliberately quiet one.
Borders carry no alpha for the same reason. On outline the fill is transparent, so the border is the chip's only boundary and owes WCAG 1.4.11's 3:1. No alpha survives that: border-<tone>-ink/70 reaches only 3.34–3.69:1 in light, which is no margin at all, and the background-token variants are far worse. Full-strength ink — the same colour as the text — gives 6.51–7.15:1 light and 6.84–12.67:1 dark. That is also why borderColor being optional matters: an outline pill whose config omits it falls back to border-muted-foreground, never to border-border, which is the decorative tier at 1.16:1.
Server components
Neither status-pill nor status-config is a client module. The pill has no state, no effects and no handlers, so a server-rendered list can use it directly and ship no JavaScript for it. The helpers work there too — useful when the same status table has to produce a label for an email or a PDF. This documentation page is itself a client component, so there is nothing to preview here — every pill above is the same static markup a server render would emit.
// No 'use client' anywhere in status-pill.tsx or status-config.ts — the pill has
// no state, no effects and no handlers, so a server-rendered list can use it
// directly and ship no JavaScript for it.
export default async function RequestsPage() {
const requests = await db.requests.findMany()
return (
<ul>
{requests.map((row) => (
<li key={row.id}>
{row.title}
<StatusPill configs={REQUEST_STATUSES} value={row.status} />
</li>
))}
</ul>
)
}Accessibility
- The pill is a plain
<span>with no role and no ARIA of its own — it is text, and screen readers read the label like any other text. That is the point: the status is never conveyed by colour alone, in any variant. - The dot in
variant="dot"isaria-hidden; the label beside it carries the meaning. - The five coloured tones clear AA 4.5:1 for their text in both themes, and every outline border clears the 3:1 non-text threshold — see the measurements above.
neutralis the exception: it inherits--muted-foreground, which the default palette knowingly leaves at 3.58:1 onbg-mutedand the high-contrast palette raises to 4.89:1. Custom tones owe both numbers. shorthides meaning from everyone, sighted or not. When you use it, pass atitle(forwarded to the span) or keep the full label reachable somewhere on the row.- An unknown status still renders its raw value rather than an empty pill, so a screen-reader user hears something is there instead of skipping a silent element.
- The pill is not interactive. If a status needs to be changed, put a real
SelectorComboboxthere — do not attach a click handler to the span, which leaves it unfocusable and unannounced as a control.