Badges, pills & chips
The small-status family — compact labels that annotate state, category or counts. Four React primitives (Badge, StatusPill, NotificationBadge, IconChip) plus the raw .chip helper classes. All draw from the same theme-aware semantic palette (good / warn / bad / accent + the surface ramp), so flip the theme in the top bar to confirm every tone re-skins. None of them ever rely on color alone: every example carries a word or an icon that names its meaning.
- <Badge>
- Tiny static label (cva: tone × size). For a category, attribute or fact next to content — “Verified”, “Beta”, “3 dogs”.
- <StatusPill>
- Status-forward pill — larger, rounded-full, with an optional leading dot and a motion-safe pulse for live/in-progress states.
- <NotificationBadge>
- A count chip for unread/pending. Renders null at 0, clamps to “max+”, exposes an aria-label.
- <IconChip>
- The square rounded icon container used at the head of ActionCard / ListRow, with tones, sizes and a locked overlay.
- .chip / .chip-sm
- Raw CSS helper classes (globals.css) for risk/team/role tags rendered outside React — pair the base with a tone modifier.
import { Badge, StatusPill } from "@/components/ui";
<Badge tone="green">Verified</Badge>
<StatusPill tone="amber" size="md">Pending</StatusPill>Badge — tone × size matrix
Every tone crossed with every size. Rows are tones (gray · red · green · amber · blue · accent), columns are sizes (sm · md). gray is the default tone, sm the default size; size changes padding and font size only, never the color.
| tone \ size | sm | md |
|---|---|---|
| gray | gray | gray |
| red | red | red |
| green | green | green |
| amber | amber | amber |
| blue | blue | blue |
| accent | accent | accent |
- size="sm"
- px-2 py-0.5 text-[11px] — the default (defaultVariants.size). Inline within dense rows / table cells.
- size="md"
- px-3 py-1 text-sm — roomier; standalone or next to body text.
Badge — tones & meaning
The six tone keys and what each one means. Reach for the semantic tones (red / green / amber) only when the badge actually carries that state; use gray for neutral facts and blue / accent for brand-flavoured information.
- tone="gray"
- bg-surface-3 / text-muted — the neutral default (defaultVariants.tone). Categories, counts, anything non-semantic.
- tone="red"
- bg-[--color-bad-bg] / text-bad — error, blocked, overdue. Red carries meaning, never decoration.
- tone="green"
- bg-[--color-good-bg] / text-good — success, verified, active, complete.
- tone="amber"
- bg-[--color-warn-bg] / text-warn — warning, pending, needs attention.
- tone="blue"
- bg-accent-soft / text-accent — informational, in the brand (beryl) family. Same palette as accent.
- tone="accent"
- bg-accent-soft / text-accent — brand emphasis. Identical styling to blue today; kept as a distinct semantic key.
StatusPill — tone × size matrix
The same six tones as Badge, but on the larger, rounded-full pill with three sizes (sm · md · lg; md is the default). Each cell carries a status word so the meaning never rides on color. StatusPill is for state that changes — Badge is for static facts.
| tone \ size | sm | md | lg |
|---|---|---|---|
| gray | Draft | Draft | Draft |
| red | Blocked | Blocked | Blocked |
| green | Active | Active | Active |
| amber | Pending | Pending | Pending |
| blue | In review | In review | In review |
| accent | Featured | Featured | Featured |
- size="sm"
- px-2.5 py-1 text-xs, dot h-1.5 w-1.5.
- size="md"
- px-3 py-1.5 text-sm, dot h-2 w-2 — the default (defaultVariants.size).
- size="lg"
- px-4 py-2 text-base, dot h-2.5 w-2.5 — hero / header status.
StatusPill — dot & pulse
Two booleans add a leading status dot. dot shows a solid dot tinted to the tone (currentColor); pulse adds an animated ping ring for live / in-progress states and implies dot. The pulse is gated behind motion-safe, so it goes still under prefers-reduced-motion.
- dot
- boolean (default false). Adds a solid leading dot tinted to the tone via bg-current. Dot size tracks the pill size (sm/md/lg → 1.5/2/2.5).
- pulse
- boolean (default false). Adds a motion-safe animate-ping ring behind the dot for live/in-progress states. Implies dot — you don’t need both.
- reduced motion
- The ping is wrapped in motion-safe:, so under prefers-reduced-motion the dot is shown without animation.
- aria-hidden dot
- The dot/pulse element is aria-hidden — meaning is carried by the pill’s text label, never the animation.
NotificationBadge — counts
A round count chip for unread / pending items. It renders nothing at 0 (or below), so you can pass a raw count unconditionally, and clamps anything over max (default 99) to “max+”. tabular-nums keeps the width steady as the number changes.
- count
- number (required). Renders null when <= 0. Pass it raw — no need to conditionally hide the badge yourself.
- max
- number (default 99). Counts above this display as `${max}+` (e.g. 100 → 99+).
- label
- Optional (count) => string for the aria-label, receiving the raw unclamped count. Default: `${count} new`.
- role=status
- The badge is a live region with an aria-label; the visible text is aria-hidden so AT reads the friendlier label, not “99+”.
NotificationBadge — tones
Three tones. red is the default (urgent / unread); accent is the brand-toned alternative; gray is the quiet, low-priority option. The clamp and null-at-zero behaviour is identical across all three.
- tone="red"
- bg-bad / text-accent-foreground — the default. Unread / urgent counts.
- tone="accent"
- bg-accent / text-accent-foreground — brand-toned counts.
- tone="gray"
- bg-surface-3 / text-muted — quiet, low-priority counts.
IconChip — tone × size matrix
The square, rounded icon container that heads an ActionCard or ListRow. Three tones (indigo · gray · green; indigo is the default) crossed with three sizes (sm · md · lg). The box rounds harder as it grows (md → rounded-md, lg → rounded-xl); pick a glyph size that fills the box (h-4/h-5/h-6).
| tone \ size | sm | md | lg |
|---|---|---|---|
| indigo | |||
| gray | |||
| green |
- size="sm"
- h-7 w-7 rounded-md — compact list rows.
- size="md"
- h-9 w-9 rounded-lg — the default (size prop default).
- size="lg"
- h-11 w-11 rounded-xl — card headers / hero rows.
- children
- Typically a lucide icon already sized to the chip. Decorative — mark it aria-hidden; name the chip’s context in the surrounding row.
IconChip — tones & locked state
The three tones with real glyphs, plus the locked state. locked dims the icon (opacity-60 grayscale) and overlays a small lock badge bottom-right — use it to mark a tool or action the viewer can’t yet access. When locked, the glyph is hidden from assistive tech (the lock carries the meaning), so label the lock’s reason in the surrounding row.
- tone="indigo"
- bg-accent-soft / text-accent — the default (brand beryl). The usual tool/section chip.
- tone="gray"
- bg-surface-3 / text-muted — neutral / inactive.
- tone="green"
- bg-[--color-good-bg] / text-good — positive / completed.
- locked
- boolean (default false). Dims the icon (opacity-60 grayscale) and adds a bottom-right lock badge. The glyph becomes aria-hidden — surface the locked reason in the row/card text.
Raw .chip / .chip-sm helper classes
CSS-only helper classes (src/app/globals.css) for risk / team / role tags rendered outside the React layer — e.g. server-rendered roster tables. Pair the base .chip (or the denser .chip-sm) with one tone modifier. Unlike Badge/StatusPill these are pill-shaped (radius-pill) and carry a tinted border. The risk tones (red/yellow/green) are deliberately semantic — they encode risk level, not decoration.
- .chip
- Base class: inline-flex pill, padding 2px 10px, 12px / weight 540, radius-pill, 1px transparent border. Needs a tone modifier for color.
- .chip-sm
- Denser modifier: padding 1px 8px, 11px text. Combine as `chip chip-sm chip-<tone>`.
- .chip-neutral
- Neutral — surface-3 fill, muted text, border edge.
- .chip-red
- Risk: red (semantic — danger/high).
- .chip-yellow
- Risk: amber (semantic — caution/medium).
- .chip-green
- Risk: green (semantic — safe/low).
- .chip-husky
- Team: husky → accent (beryl).
- .chip-bulldog
- Team: bulldog → red.
- .chip-terrier
- Team: terrier → green.
- .chip-lead
- Role: lead → accent.
- .chip-director
- Role: director → accent-strong.
- .chip-advise-against
- Flag: advise-against → warn/amber.
When to use which
Four primitives plus the raw helpers overlap visually — here’s how to choose. The rule of thumb: Badge for a static fact, StatusPill for a changing state, NotificationBadge for a count, IconChip for a section/tool icon, and the .chip classes only when you can’t reach for a React component.
- Badge
- A static attribute or fact attached to content — role, category, “Beta”, a small count label. It doesn’t imply the thing is changing.
- StatusPill
- A lifecycle state that can change — Active / Pending / Blocked / Live. Reach for it (not Badge) when you want the dot or the live pulse.
- NotificationBadge
- A pure numeric count of unread / pending items, usually anchored to an icon or nav item. Hides itself at 0.
- IconChip
- Not a label at all — the square icon container at the head of an ActionCard / ListRow, with the locked overlay for gated tools.
- .chip / .chip-sm
- Last resort: risk/team/role tags in CSS-only / server-rendered markup where importing a React component isn’t practical.
- never color alone
- Across all of them, meaning rides on a word or icon — the hue only reinforces it (needed for color-blind users and AT).