Badges, Chips and Tags
Which pill-shaped component to use, and why you must not build your own.
Rule zero: do not build your own
If you are about to write a span with a border-radius, some padding and a background tint — stop. The thing you want already exists.
Four hand-rolled copies of the badge existed in the app before this rubric. They drifted to four different heights, three of them could not be reached by keyboard, and one leaked a raw hex into an ordinary label. That is the cost, every time, and it is why the primitives below are deliberately narrow.
There are exactly four components to choose from, and the choice is mechanical. Answer two questions.
The decision
Question 1 — can a person click or dismiss it?
Yes → Chip. Stop here.
Chip renders a real button, so it takes focus, fires on Enter and Space, and a removable one announces "Remove <label>". Nothing else on this page is allowed to be interactive.
No → question 2.
Question 2 — who chose the colour?
| The colour comes from | Use |
|---|---|
The design system — one of the semantic values (green, orange, red…) | Badge |
| A user, marking a status in a lifecycle — lead status, deal stage, location state | StatusBadge |
| A user, marking a classification where the colour is the meaning — menu, catalog | Tag |
That is the whole rubric. There is no fifth case.
At a glance
| Use for | Interactive | Colour from | Height | |
|---|---|---|---|---|
Badge | A status or label the app states — "Pending", "Unverified", a brand name | no | semantic enum only | derived |
Chip | The same, but a person clicks or dismisses it | yes — real button | semantic enum only | derived |
StatusBadge | A user-created status: lead status, deal stage, location state | no | user's colour — dot or icon only | derived |
Tag | A user-created classification where colour is the point | no | user's colour — tints the surface | derived |
Badge and Chip are the primitives. StatusBadge and Tag are compositions built on Badge — which is exactly why you must not build a fifth one. They inherit the shared recipe, so they stay in agreement for free.
StatusBadge vs Tag — the one genuinely subtle call
Both carry a colour the user picked. The difference is how far the colour is allowed to travel.
StatusBadgekeeps the colour on a dot or icon and leaves the pill white. A status is one value in a lifecycle, read down a column. The label is what you scan; the colour is a secondary cue. If the colour flooded the pill, a column of statuses would read as a column of alarms.Taglets the colour tint the background and the text. A classification is read at a glance among siblings, and the colour is the coding — that is the entire reason it exists.
So ask: is the colour the thing you are reading, or a hint about the thing you are reading?
Colour is the content → Tag. Colour is a hint → StatusBadge.
This is also why Tag requires a colour. Without one, every tag renders the same blue dot and implies a coding that does not exist. That is a Badge. This is not a style preference — 15 lead-tag call sites were doing exactly this, and they became badges that keep a plain dot.
The three hard rules
- Clickable →
Chip. Always.Badgehas noonClick, deliberately, so the inaccessible version cannot be written. If you wantonClickon aBadge, you want aChip. - Colour is user data →
StatusBadgeorTag. NeverBadge.Badgeaccepts only the semantic enum, so a raw hex cannot leak into an ordinary label. The single escape hatch isdotColor, which tints the dot and nothing else. - If the row around it is already the control, the thing inside stays a
Badge. A picker row is itself abutton; aChipin there would nest a button inside a button, which is invalid and breaks keyboard navigation.
Violations, and their fixes
// ✗ a hand-rolled badge — drifts from the recipe the moment either side changes
<span className="rounded-md border px-1.5 py-0.5 text-md bg-green-bg-subtle">
{label}
</span>
// ✓
<Badge color="green" label={label} />// ✗ clickable but not keyboard-operable — this is the bug class, 22 of them
<span onClick={apply} className="…badge-ish…">Filter</span>
// ✓
<Chip color="gray" label="Filter" onClick={apply} />// ✗ a raw hex on an ordinary label
<Badge label={status.name} style={{ background: status.color }} />
// ✓ colour stays on the marker, surface stays on the token
<StatusBadge label={status.name} color={status.color} iconId={status.iconId} />// ✗ pinning a badge's height — it must respond to the type scale
<Badge label="Live" className="h-6" />
// ✓ pick the size step; the height follows
<Badge label="Live" size="sm" />The only legitimate reason to hand-roll
You need behaviour none of the four support, and you have checked that adding it to Chip or Badge is wrong.
In practice this has been true once. DataTable.ChipFilters keeps its own implementation: it is already accessible (aria-pressed) and pins h-6 on purpose, so migrating it would opt those filters into type-scale response — a behaviour change rather than a tidy-up. For the same reason Chip has no selected prop; there is no second consumer.
If you think you have found the second case, the fix is usually a new prop on Chip, not a new component.
Heights
A badge's height is not yours to set. It derives from the type token plus padding plus border, which is what lets the same badge sit correctly on both surfaces — the franchisee portal runs larger type than the brand dashboard, and a badge frozen at a brand-dashboard pixel would crowd its own label there.
Anything that takes a full slot in a row does pin its height, to a rung of the control scale. See Control Heights.
Known rough edges
Tagcontrast is unclamped. Its colour comes from a free hex picker and drives both background and text, so a poor pick can hurt contrast. The icon side is safe —IconPickeroffers 172 curated icons.- Popover triggers do not announce that they open something — no
aria-haspopup/aria-expanded, app-wide, including the status picker. - Picker rows nest a
Checkboxand a menu button inside a rowbutton. - No lint rule stops the next hand-rolled badge. This page is the guard.
- ~85 other chip-shaped elements still size themselves ad hoc and need per-site judgement.