React

Status badges: text and colour, never colour alone

A status badge states the status in words and confirms it with colour: four variants, one label per state, 4.5:1 contrast on small text.

Key points

  • Text is the information, colour is the confirmation: WCAG 2.2 forbids carrying meaning in colour alone.
  • The four `Badge` variants cover almost every state when each one maps to a fixed label.
  • Badge text is small, so it needs 4.5:1 contrast, not 3:1.

Short answer: a status badge always carries a written label and uses colour as confirmation. Map every state to a Badge variant and a fixed label, then check contrast at 4.5:1, because the text is small.

Four variants, five states

StateVariantLabel
Activedefault“Active”
Pendingsecondary“Pending”
Faileddestructive“Failed”
Draftoutline“Draft”
Archivedoutline with muted text“Archived”

The map lives in a typed object, not in a chain of if blocks. A new state then fails to compile until it has its label, and the text cannot fall behind the colour.

import { Badge } from "@/components/ui/badge";

const tones = {
  active: { variant: "default", label: "Attivo" },
  pending: { variant: "secondary", label: "In attesa" },
  failed: { variant: "destructive", label: "Fallito" },
  draft: { variant: "outline", label: "Bozza" },
} as const;

export type State = keyof typeof tones;

export function StatusBadge({ state }: { state: State }) {
  const tone = tones[state];
  return {tone.label};
}

The colour of a fifth state

A yellow warning is not a Badge variant: it is a new token. It arrives through the three theme steps, light value, dark value and mapping in @theme inline, then a local variant in the component file. The procedure is in how to theme shadcn.

Shape, size and announcements

  • Dot plus text for compact lists: the dot is decorative, so mark it aria-hidden.
  • No icon-only badge: a green circle does not say “active” to someone who cannot see green.
  • For a status that changes by itself, put role="status" on the container, not on the badge, and do not announce on every render.

Source: WCAG 2.2 criterion 1.4.1 forbids carrying information in colour alone, and criterion 1.4.3 asks for 4.5:1 on normal-size text, which is the size of a badge; the shadcn/ui badge.tsx component ships under the MIT licence.

A named limitation

The destructive variant is meant for actions, not states: on a single “Failed” badge it works, but across a table of ten failed rows it becomes noise and loses the signal. A second limit: a badge is not a live region, so a state that changes after a fetch is never announced by a screen reader until you add role="status" to the right container.

Where to continue

In the catalogue you find the shadcn library, a badge block with preview and the dashboard category blocks. The method for measuring colour pairs is in UI palettes and measured contrast, the tokens in how to theme shadcn; the comparison of ready-made themes is free versus premium shadcn themes. For a badge inside a long table read the best React table libraries; for status in words rather than colour, an accordion for FAQs.

Frequently asked questions

Can I use only a coloured dot, with no text?

No. WCAG 2.2 criterion 1.4.1 requires that colour is not the only carrier of information: you need a written label, or an `aria-label` that replaces it.

Which contrast does badge text need?

4.5:1, because badge text is small: the 3:1 threshold only covers large text, 18.66px bold or 24px regular.

How do I add a yellow warning state?

Define `--warning` and `--warning-foreground` in `:root` and `.dark`, map them in `@theme inline`, then add a local variant to `badge.tsx`. Without the mapping the utility does not exist.

Should the badge be a link or a button?

Neither: it is a label. If it must filter a table, put the filter on a `Button` beside it and leave the badge as text, or the user cannot tell what is clickable.

Do screen readers announce a status change?

Not from the badge. Add `role="status"` to the container that changes and let the badge text be the announced content, without `aria-live` on the badge itself.