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.

DraftSubmittedIn reviewApprovedRejectedEscalated

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 reviewApprovedRejectedEscalated
outlineDraftSubmittedIn reviewApprovedRejectedEscalated
dotDraftSubmittedIn reviewApprovedRejectedEscalated

The 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.

TonesoftoutlinedotClasses
neutralneutralneutralneutralbg-muted
text-muted-foreground
border-muted-foreground
primaryprimaryprimaryprimarybg-primary/20
text-primary-ink
border-primary-ink
successsuccesssuccesssuccessbg-success/15
text-success-ink
border-success-ink
warningwarningwarningwarningbg-warning/20
text-warning-ink
border-warning-ink
destructivedestructivedestructivedestructivebg-destructive/15
text-destructive-ink
border-destructive-ink
infoinfoinfoinfobg-info/15
text-info-ink
border-info-ink
  • warning and primary use /20 where the others use /15. Yellow needs the extra step to separate from the page at all: at /15 the primary fill reads only 1.10:1 against the page, and /20 lifts it to 1.13:1 — level with warning, the other tone that needs the extra step.
  • primary follows --primary, not the gold-* scale. The old hard-coded gold classes had adequate contrast but the wrong role: the raw scale has no dark variant, so it needed dark: overrides, and an app that re-branded --primary still got a yellow “primary” chip.
  • neutral is the fallback tone: any value missing from configs renders as bg-muted + text-muted-foreground.
  • A tone is only three strings, so custom ones are trivial — keep them to background-role tokens for bgColor and 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.

DRFSUBREVAPRREJESC

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.

on_holdunknown value, no fallback → renders the raw value
Unknown statusunknown value + fallbackLabel
null, no fallback → an em dash

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'
The same classes on a shape that is not a pill:
REV
PropTypeDefaultDescription
getStatusConfig(configs, value) => StatusConfig<T> | undefinedFinds the entry by value. Returns undefined for null, undefined, or a value not in the table.
getStatusClasses(configs, value) => stringReturns `${bgColor} ${textColor}` for use on any element. Returns an empty string when nothing matches, so supply your own fallback.
getStatusLabel(configs, value, options?) => stringDisplay 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.

Legend, always in priority order:
ESCREJREVSUBAPRDRF
  • RQ-1042Warehouse extension — Hai PhongApproved
  • RQ-1043Fuel surcharge revisionDraft
  • RQ-1044Container fleet write-offEscalated
  • RQ-1045Overtime budget, Q3Submitted
  • RQ-1046Customs broker contract renewalIn review
  • RQ-1047Third-party haulage ratesRejected
  • RQ-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.

row-highlight.tsx
// 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

PropTypeDefaultDescription
configsrequiredreadonly StatusConfig<T>[]Your app's whole status table. Passed on every pill — the component holds no registry of its own.
valuerequiredT | null | undefinedWhich 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.
shortbooleanfalseUse shortLabel when the config has one; falls back to label when it does not.
fallbackLabelstringText to show when value is not in configs. Without it the raw value is shown, or an em dash for null/undefined.
classNamestringExtra classes, merged after the tone classes — so anything here wins.
...restOmit<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.

PropTypeDefaultDescription
valuerequiredTThe status key as your data stores it. Matched by strict equality.
labelrequiredstringHuman text. There is no labels prop here — this table is where your strings live, and it is yours to translate.
shortLabelstringAbbreviation for narrow columns and chips. Used only when short is set.
prioritynumberSort weight, lower first. Read by sortByStatusPriority; configs without one sink to the end in declaration order.
bgColorrequiredstringTint class for the soft variant, e.g. bg-success/15. From StatusTone.
textColorrequiredstringText class — must be an ink token (text-success-ink), never the background token. From StatusTone.
borderColorstringBorder class for the outline variant, e.g. border-success-ink. Optional, but omit it and outline pills fall back to border-muted-foreground.
rowClassNamestringClasses for a whole table row in this status. StatusPill ignores it; DataTable reads it through its own rowClassName callback.

StatusTone fields

PropTypeDefaultDescription
bgColorrequiredstringBackground tint class. A background-role token, solid or at /10–/25.
textColorrequiredstringForeground class. Must be the ink step: the text sits on a tint of its own colour and owes AA 4.5:1.
borderColorstringBorder 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:

WrongMeasured
text-success on bg-success/152.69:1 — below even the 3:1 non-text floor
text-info on bg-info/153.44:1
text-destructive on bg-destructive/154.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:

Tonesoft (on its own tint)outline · dot (on the page)
success5.60:1 L · 7.44:1 D6.52:1 L · 10.85:1 D
info5.54:1 L · 7.12:1 D6.68:1 L · 10.16:1 D
destructive5.76:1 L · 5.71:1 D7.15:1 L · 6.84:1 D
warning5.93:1 L · 7.19:1 D6.51:1 L · 12.35:1 D
neutral ✗3.58:1 L · 4.21:1 D3.83:1 L · 4.94:1 D
primary5.77:1 L · 7.26:1 D6.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.

app/requests/page.tsx
// 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" is aria-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. neutral is the exception: it inherits --muted-foreground, which the default palette knowingly leaves at 3.58:1 on bg-muted and the high-contrast palette raises to 4.89:1. Custom tones owe both numbers.
  • short hides meaning from everyone, sighted or not. When you use it, pass a title (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 Select or Combobox there — do not attach a click handler to the span, which leaves it unfocusable and unannounced as a control.