Badge
.badge is a small status pill. Its padding is
.18em .55em and its font size is
.75rem, so it scales with whatever
text it sits beside instead of needing a size variant for every context. Put one in
a heading and it grows; put one in a table cell and it shrinks.
When to use it
Use a badge for a short, categorical value attached to something else: a status, a count, a label. The word inside it should be one or two words that a reader can take in without reading — "Paid", "Draft", "3 open".
white-space: nowrap is set deliberately. A badge that wraps to two lines
has stopped being a badge, so if the text is long enough to wrap, the text is wrong
rather than the badge.
Invoice INV-2291 Paid
Same badge in small text Paid
<h3 class="cluster cluster-tight">Invoice INV-2291 <span class="badge badge-good">Paid</span></h3>
<p class="text-sm cluster cluster-tight">Same badge in small text <span class="badge badge-good">Paid</span></p>
<h3 class="cluster cluster-tight">Invoice INV-2291 <span class="badge badge-good">Paid</span></h3>
<p class="text-sm cluster cluster-tight">Same badge in small text <span class="badge badge-good">Paid</span></p>
Tones
Six looks. Each sets background, color and
border-color and changes nothing else, so a tone never resizes a badge
or moves what is around it.
Neutral
The default. Grey fill, muted text, hairline border.
<span class="badge">Neutral</span>
<span class="badge">Neutral</span>
Brand
Brand-tinted. For a label that is a category rather than a status.
<span class="badge badge-brand">Brand</span>
<span class="badge badge-brand">Brand</span>
Good
Paid, shipped, passing, active.
<span class="badge badge-good">Good</span>
<span class="badge badge-good">Good</span>
Warn
Pending, expiring, needs attention but nothing is broken.
<span class="badge badge-warn">Warn</span>
<span class="badge badge-warn">Warn</span>
Bad
Failed, overdue, rejected.
<span class="badge badge-bad">Bad</span>
<span class="badge badge-bad">Bad</span>
Solid
Filled with the brand colour. Highest emphasis; use it once.
<span class="badge badge-solid">Solid</span>
<span class="badge badge-solid">Solid</span>
Leading dot
.badge-dot adds a ::before circle in
currentColor, which means it takes the tone's text colour automatically
and no extra markup is involved. It reads as a status light rather than a label.
<span class="badge badge-dot badge-good">Live</span>
<span class="badge badge-dot badge-warn">Degraded</span>
<span class="badge badge-dot badge-bad">Down</span>
<span class="badge badge-dot">Unknown</span>
<span class="badge badge-dot badge-good">Live</span>
<span class="badge badge-dot badge-warn">Degraded</span>
<span class="badge badge-dot badge-bad">Down</span>
<span class="badge badge-dot">Unknown</span>
The dot is decorative and carries no meaning of its own. Colour alone is not a
status — the word beside it is what a colour-blind reader and a screen reader both
rely on. Never ship .badge-dot with no text in it.
In context
Badges are almost always inside something else. The two places they earn their keep are the end of a table row and beside a title.
| Invoice | Status |
|---|---|
| INV-2291 | Paid |
| INV-2292 | Pending |
| INV-2293 | Overdue |
<div class="table-wrap">
<table class="table">
<thead><tr><th scope="col">Invoice</th><th scope="col">Status</th></tr></thead>
<tbody>
<tr><th scope="row">INV-2291</th><td><span class="badge badge-good">Paid</span></td></tr>
<tr><th scope="row">INV-2292</th><td><span class="badge badge-warn">Pending</span></td></tr>
<tr><th scope="row">INV-2293</th><td><span class="badge badge-bad">Overdue</span></td></tr>
</tbody>
</table>
</div>
<div class="table-wrap">
<table class="table">
<thead><tr><th scope="col">Invoice</th><th scope="col">Status</th></tr></thead>
<tbody>
<tr><th scope="row">INV-2291</th><td><span class="badge badge-good">Paid</span></td></tr>
<tr><th scope="row">INV-2292</th><td><span class="badge badge-warn">Pending</span></td></tr>
<tr><th scope="row">INV-2293</th><td><span class="badge badge-bad">Overdue</span></td></tr>
</tbody>
</table>
</div>
Classes
Generated from src/07-components.css by
tools/docs/extract.mjs.
| Class | Layer | What it does | Source |
|---|---|---|---|
.badge |
deck.components |
A small inline status pill. Sized in em, so it tracks whatever text it sits next to rather than needing a size variant per context. | 07-components.css:81 |
.badge-bad |
deck.components |
Failed, overdue, rejected. | 07-components.css:102 |
.badge-brand |
deck.components |
Brand tint. For a label that names a category rather than a status. | 07-components.css:96 |
.badge-dot |
deck.components |
Adds a leading dot in currentColor, so it takes the tone's text colour without a second class. The dot is decoration — never ship one with no text beside it, because colour alone is not a status. | 07-components.css:109 |
.badge-good |
deck.components |
Paid, shipped, passing, active. | 07-components.css:98 |
.badge-solid |
deck.components |
Filled with the brand colour. The highest-emphasis badge; use one per view or it stops meaning anything. | 07-components.css:105 |
.badge-warn |
deck.components |
Pending, expiring: needs attention, nothing is broken. | 07-components.css:100 |
Tokens
The badge reads the status ramps directly. Each tone uses the 100 step
for its fill and the 700 step for its text, which is the pairing Deck
uses everywhere a tint needs readable text on it.
| Token | Live | Declared value |
|---|---|---|
--surface-2 |
light-dark(var(--ink-25), var(--ink-900)) |
|
--text-muted |
light-dark(var(--ink-600), var(--ink-400)) |
|
--line |
light-dark(var(--ink-200), oklch(28% .016 var(--hue-neutral))) |
|
--r-xs |
4px |
|
--text-xs |
.75rem |
|
--brand-soft |
light-dark(var(--brand-50), oklch(26% .05 var(--hue-brand))) |
|
--brand-soft-text |
light-dark(var(--brand-800), var(--brand-200)) |
|
--good-100 |
oklch(94% calc(var(--chroma-status) * .3) var(--hue-good)) |
|
--good-700 |
oklch(44% var(--chroma-status) var(--hue-good)) |
|
--warn-100 |
oklch(95% calc(var(--chroma-status) * .32) var(--hue-warn)) |
|
--warn-700 |
oklch(50% var(--chroma-status) var(--hue-warn)) |
|
--bad-100 |
oklch(94.5% calc(var(--chroma-status) * .3) var(--hue-bad)) |
|
--bad-700 |
oklch(44% calc(var(--chroma-status) * 1.1) var(--hue-bad)) |
|
--brand-600 |
oklch(51.6% var(--chroma-brand) var(--hue-brand)) |
|
--text-on-brand |
oklch(99% .01 var(--hue-brand)) |
Accessibility
-
A badge is a
<span>with no role. A screen reader reads the text and nothing more. That is usually right — "Paid" in a status column reads as "Paid" — but it also means the badge does not announce what it is a status of. Give the column a header, or add.sr-onlytext. - Colour is never the message. The tone is a second signal on top of the word. A badge whose only content is a colour has no accessible name at all.
-
Not a live region. Changing a badge's text with JavaScript
announces nothing. If a status change needs to be heard, put it in a
.toastor an element witharia-live. -
Contrast. The
100/700pairing clears 4.5:1 in both themes..badge-soliduses--text-on-brand, which is computed against the brand rather than hard-coded, so it stays readable if the brand hue is changed. -
It is not interactive. There is no focus style, because there is
nothing to focus. A badge that needs to be clicked is a
.chipor a.btn-sm.
Right to left
The badge uses gap and padding shorthands with no
side-specific values, so dir="rtl" needs nothing. The dot moves to the
right-hand side on its own because it is a flex child rather than a positioned
element.
<span dir="rtl" class="badge badge-dot badge-good">مدفوعة</span>
<span dir="rtl" class="badge badge-warn">قيد الانتظار</span>
<span dir="rtl" class="badge badge-dot badge-good">مدفوعة</span>
<span dir="rtl" class="badge badge-warn">قيد الانتظار</span>
Reduced motion
Nothing about a badge animates or transitions, so
prefers-reduced-motion changes nothing here. This section exists because
every component page has it; for this component there is genuinely nothing to say.
Printing
Print stylesheets usually strip backgrounds, which would turn every badge into plain
text and lose the status. src/99-print.css lists .badge
among the elements that keep their fill, with
print-color-adjust: exact, so a printed invoice still shows Paid as
green.
Overriding it
The badge has no local custom properties; the tones set
background and color directly. Override in
app.components, which beats every Deck layer regardless of specificity.
@layer app.components {
.badge {
border-radius: var(--r-full);
text-transform: uppercase;
letter-spacing: .04em;
}
}
Adding a tone of your own is four declarations and needs no Deck class:
background, color, border-color: transparent,
and nothing else.
When not to use it
-
Not for something clickable. Use
.chip, which has a hover state, a focus ring and a 44px target, or a.btn-sm. A badge with a click handler is an unfocusable control. -
Not for a removable filter. That is
.chipwith a remove button. Badges have no affordance for being dismissed. -
Not for a count on an icon. Use
.indicator, which positions itself over its parent. A badge is inline and will sit beside the icon rather than on it. -
Not for a sentence.
white-space: nowrapmeans a long badge pushes its container wide instead of wrapping, and on a phone that is a horizontal scrollbar. If it is a sentence, it is an.alert. -
Not as a heading-level label. A badge in an
<h1>inherits the heading's size throughemand becomes very large. That is the em sizing working correctly; it is still usually not what you want. Wrap it in a<small>or use.text-smon the badge's parent.