Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground
Healthy

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.

subtle

neutralaccentsuccesswarningdangerinfo

solid

neutralaccentsuccesswarningdangerinfo

outline

neutralaccentsuccesswarningdangerinfo

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.

api-gatewayLive
billing-workerDegraded
legacy-syncFailed
edge-cacheDraft

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.

11
1212
9999
99+250
3overlay

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.

Colour
LiveDegradedFailed
Greyscale
LiveDegradedFailed

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.

Draft
Neutral
Beta
Accent
Live
Success
Degraded
Warning
Failed
Danger
Queued
Info
Critical
SolidOne per page
Preview
Outline
Verified
With icon
New
Small
12
Count
99+
Overflow

Anatomy

Every part, every measurement, and the reason it is that number.

LiveFailed12

Subtle, solid, and count. All three share a height and a fully rounded shape so they align in any row.

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

  2. ShapeFully rounded

    Pills are labels; 8px radius is interactive. Users read the shape before the word.

  3. Padding8px horizontal

    Roughly 0.36× the height. Tighter and the text touches the curve; looser and short labels float in an oversized pill.

  4. Status dot6px, 6px gap

    The redundant encoding. It carries the tone at full saturation while the text stays legible against a soft tint.

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

  6. Type12px / 500, tabular

    Tabular figures so a count badge does not change width as the number changes.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
padding-xHorizontal padding
gapDot or icon to label

Radius

TokenValueUsed for
full—Pill shape

Typography

TokenValueUsed for
--text-captionMedium label
--text-overlineSmall label

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightPaddingRadiusTypeMin widthWhen to use
Small18px0 6pxfull11px / 50018pxInside table cells, next to a title, in dense toolbars.
Medium22px0 8pxfull12px / 50022pxThe default. List rows, cards, page headers.
Count18px0 4pxfull11px tabular18pxNavigation items and tabs. Caps at 99+.
Dot only8px—full——A presence indicator with no number. Needs an accessible name.

Do

LiveDegradedFailed
Pair the tone with a wordThe word is what survives greyscale, colour blindness and high-contrast mode. The colour makes it faster to find; it should never be what makes it understandable.
Live · Degraded · Failed · Draft · Queuednot: Online, Up, Running, Active, Healthy…
Keep the vocabulary closedLive, Degraded, Failed, Draft, Queued. Five words used consistently across the whole product beat twenty synonyms that all mean roughly the same thing.
99999+
Cap counts and use tabular figuresA badge that grows from 9 to 100 pushes the layout around. Capping at 99+ fixes the width, and tabular figures stop the digits jittering as the value updates.
service-1Live
service-2Live
service-3Degraded
service-4Live
Prefer subtle when several badges share a screenA table with fifty solid badges is a mosaic. Subtle keeps the row readable and lets the one solid badge on the page actually mean something.

Don't

Do not make a badge clickableThe pill shape and the small size say "label". A badge that navigates or filters is a chip, and pretending otherwise means users never discover the interaction.
Do not rely on colour aloneThese three are indistinguishable in greyscale, to a deuteranope, and in Windows High Contrast Mode. There is simply no information present.
Certificate expires in six days and renewal is currently blocked
Do not put a sentence in a badgePast about three words the pill stops being scannable and starts being a paragraph with rounded corners. Long status messages belong in an alert.
api-gatewayLiveBetav4Migrating
Do not stack more than two badges on one itemThree or more competing labels means none of them is read. Pick the one that changes what the user does next.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.4.1Use of ColorA1.4.3Contrast (Minimum)AA1.4.11Non-text ContrastAA

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

TabSkips 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.
AttributeApplied toNotes
No roleStatic badgesA badge is text. Adding role="status" to a static label makes screen readers announce it on every re-render.
aria-labelCountBadge"3 unread notifications" rather than the bare number, which announces as a meaningless "3".
aria-live="polite"A badge that changesOnly when the change is important and the user did not cause it — a build going from Queued to Failed.
sr-only textDot-only indicatorsA coloured dot with no text is invisible to assistive tech. Add a visually hidden word.

Code

Example usage

tsx
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

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

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

PropTypeDefaultDescription
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.
dotbooleanfalseAdds a 6px status dot. The redundant encoding for colour.
iconReactNode—Leading glyph, 11px. Use instead of a dot when the icon adds meaning.

CountBadge

PropTypeDefaultDescription
count*number—Renders nothing when zero — no empty badge.
maxnumber99Values above this render as "99+".
toneTone'accent'Use danger only for genuinely urgent counts.

Notes

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.