Badge
A read-only label that states a status. Never clickable, never the only carrier of meaning, and always paired with a word.
Also called Tag, Pill, Label, Status Indicator, Counter, Dot — in this system all of them are Badge.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Tones and variants
Subtle is the default. Solid is for the one badge that must interrupt. Outline is for dense surfaces where even a tint is too much.
In context
Badges earn their space by removing a sentence. "Live · 3 regions" as a badge and a caption beats a paragraph explaining the same thing.
Counts
Tabular figures so the badge does not jitter as the number changes, and a cap at 99+ so it cannot grow the row it sits in.
The greyscale test
Run any badge row through a greyscale filter. If you can still tell the states apart, the colour was reinforcing. If not, it was load-bearing.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
Subtle, solid, and count. All three share a height and a fully rounded shape so they align in any row.
- Height22px (md), 18px (sm)
Below a chip (28px) and well below a button (36px). The size difference is itself a signal that this is not pressable.
- ShapeFully rounded
Pills are labels; 8px radius is interactive. Users read the shape before the word.
- Padding8px horizontal
Roughly 0.36× the height. Tighter and the text touches the curve; looser and short labels float in an oversized pill.
- Status dot6px, 6px gap
The redundant encoding. It carries the tone at full saturation while the text stays legible against a soft tint.
- Fill and ring14% tint + 1px inset ring
An inset ring rather than a border, so the badge does not grow by 2px and break the alignment of a row of them.
- Type12px / 500, tabular
Tabular figures so a count badge does not change width as the number changes.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-success-subtle | — | Subtle fill |
| --ds-success-border | — | Inset ring |
| --ds-success-text | — | Label — certified against the subtle fill |
| --ds-success | — | Solid fill and the status dot |
| --ds-success-fg | — | Label on the solid fill |
| --ds-layer-active | — | Neutral fill |
Spacing
| Token | Value | Used for |
|---|---|---|
| padding-x | Horizontal padding | |
| gap | Dot or icon to label |
Radius
| Token | Value | Used for |
|---|---|---|
| full | — | Pill shape |
Typography
| Token | Value | Used for |
|---|---|---|
| --text-caption | Medium label | |
| --text-overline | Small label |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Radius | Type | Min width | When to use |
|---|---|---|---|---|---|---|
| Small | 18px | 0 6px | full | 11px / 500 | 18px | Inside table cells, next to a title, in dense toolbars. |
| Medium | 22px | 0 8px | full | 12px / 500 | 22px | The default. List rows, cards, page headers. |
| Count | 18px | 0 4px | full | 11px tabular | 18px | Navigation items and tabs. Caps at 99+. |
| Dot only | 8px | — | full | — | — | A presence indicator with no number. Needs an accessible name. |
Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- The label must reach 4.5:1 against the fill. That is why -text exists as a separate token from the base colour.
- The status dot is a meaningful graphic and must reach 3:1 against the fill it sits on.
- Solid badges use the -fg token, which is verified against the solid fill. Using -text there would fail.
Keyboard
| Tab | Skips badges entirely — they are not interactive and must never be focusable. |
Screen readers
- A badge is read as part of its surrounding content: "api-gateway, Live". That is why the word matters more than the colour.
- Do not put a badge before the thing it describes in the DOM — it will be announced before the user knows what it applies to.
- For a count badge, the accessible name should include the noun. "12" alone is meaningless out of context.
Focus & touch
- Badges are never focusable. If it needs focus, it is a chip or a button.
- Badges are not touch targets. If a badge is inside a clickable row, the row is the target and the badge must not intercept the tap.
| Attribute | Applied to | Notes |
|---|---|---|
| No role | Static badges | A badge is text. Adding role="status" to a static label makes screen readers announce it on every re-render. |
| aria-label | CountBadge | "3 unread notifications" rather than the bare number, which announces as a meaningless "3". |
| aria-live="polite" | A badge that changes | Only when the change is important and the user did not cause it — a build going from Queued to Failed. |
| sr-only text | Dot-only indicators | A coloured dot with no text is invisible to assistive tech. Add a visually hidden word. |
Example usage
1import { Badge, CountBadge } from '@/ui/Display'23// Status: tone plus a word, and usually a dot4<Badge tone="success" dot>Live</Badge>5<Badge tone="danger" dot>Failed</Badge>67// A closed vocabulary, mapped in one place8const STATUS = {9 live: { tone: 'success', label: 'Live' },10 degraded: { tone: 'warning', label: 'Degraded' },11 failed: { tone: 'danger', label: 'Failed' },12 draft: { tone: 'neutral', label: 'Draft' },13} as const1415<Badge tone={STATUS[s].tone} dot>{STATUS[s].label}</Badge>1617// Counts: capped, tabular, with a real accessible name18<CountBadge count={unread} max={99} />1920// Overlaid on an icon button21<span className="relative inline-flex">22 <IconButton label="Notifications" icon={<Bell />} />23 <span className="absolute -right-1 -top-1">24 <CountBadge count={unread} tone="danger" />25 </span>26</span>2728// Dot only — the word is still required, just hidden29<span className="inline-flex items-center gap-1.5">30 <span className="h-2 w-2 rounded-full bg-success" aria-hidden />31 <span className="sr-only">Online</span>32</span>Framework-free HTML
<span class="ds-badge ds-badge--success">
<span class="ds-badge__dot" aria-hidden="true"></span>
Live
</span>
<span class="ds-badge ds-badge--danger ds-badge--solid">Critical</span>
<!-- A count needs a noun, not just a number -->
<span class="ds-badge ds-badge--count" aria-label="12 unread notifications">12</span>
<!-- Dot only: the word still has to exist somewhere -->
<span class="ds-status">
<span class="ds-status__dot" aria-hidden="true"></span>
<span class="sr-only">Online</span>
</span>CSS
.ds-badge {
display: inline-flex;
align-items: center;
gap: 6px;
block-size: 22px;
padding-inline: 8px;
border-radius: 999px; /* pill = label, 8px = interactive */
font-size: 12px;
font-weight: 500;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* Inset ring rather than a border, so the badge does not grow by 2px
and break the alignment of a row of them. */
.ds-badge--success {
background: var(--ds-success-subtle);
color: var(--ds-success-text);
box-shadow: inset 0 0 0 1px var(--ds-success-border);
}
.ds-badge--solid.ds-badge--danger {
background: var(--ds-danger);
color: var(--ds-danger-fg);
box-shadow: none;
}
.ds-badge__dot {
inline-size: 6px;
block-size: 6px;
border-radius: 999px;
background: currentColor;
flex-shrink: 0;
}
.ds-badge--count {
min-inline-size: 18px;
block-size: 18px;
padding-inline: 4px;
justify-content: center;
}
/* High contrast mode strips the fill — keep the outline */
@media (forced-colors: active) {
.ds-badge { border: 1px solid CanvasText; }
}Component API
Badge
| Prop | Type | Default | Description |
|---|---|---|---|
| tone | 'neutral' | 'accent' | 'success' | 'warning' | 'danger' | 'info' | 'neutral' | Semantic role. Never chosen for aesthetics. |
| variant | 'subtle' | 'solid' | 'outline' | 'subtle' | Subtle by default. Solid for the one badge that must interrupt. |
| size | 'sm' | 'md' | 'md' | 18px or 22px tall. |
| dot | boolean | false | Adds a 6px status dot. The redundant encoding for colour. |
| icon | ReactNode | — | Leading glyph, 11px. Use instead of a dot when the icon adds meaning. |
CountBadge
| Prop | Type | Default | Description |
|---|---|---|---|
| count* | number | — | Renders nothing when zero — no empty badge. |
| max | number | 99 | Values above this render as "99+". |
| tone | Tone | 'accent' | Use danger only for genuinely urgent counts. |
Professional tips
- Define the status vocabulary in one file and map it to tones there. The alternative is discovering that three teams have three different words for "broken".
- A count of zero should render nothing, not a badge with a 0 in it. An empty badge is visual noise that says "no news".
- When a badge appears in a list, put it at the end of the row with a shared right edge. A ragged column of badges is much harder to scan.
- For "New" and "Beta" markers, add an expiry. A badge that says New for eight months is worse than no badge at all.
Performance
- Badges are cheap, but a table with a thousand of them is a thousand extra DOM nodes. In a virtualised list, that cost only applies to visible rows — make sure the virtualiser knows about them.
- Avoid animating badge appearance in a list. Fifty badges animating in on filter change is a strobe.
- A count badge that updates from a websocket should batch. One re-render per event on a busy stream is a common source of dropped frames.
Common mistakes
- Making a badge clickable, so the only people who discover the interaction are the ones who click everything.
- Using tone="danger" for a category that just happens to be red in the brand palette. Red means failed.
- Announcing every badge change with aria-live, so a table of statuses reads itself aloud continuously.
- Placing a badge before its subject in the DOM, so screen readers announce "Live, api-gateway".
- Letting a count badge grow unbounded, so a four-digit number pushes the navigation label out of the sidebar.
Real-world recommendations
- Status badges are the most-read element in an operations product. Spend the time on the vocabulary — it matters more than the styling.
- When a status has a cause, make the row expandable rather than putting the cause in the badge. "Failed" plus a reason on click beats a 60-character pill.
- Audit for badge inflation quarterly. Products accumulate New, Beta, Preview, Early Access and Labs badges until none of them means anything.
- In a table, a badge column should be sortable by severity, not alphabetically. Users scanning for problems want the failures first.