Overview
<Badge /> is the smallest stylistic primitive in apx-ds. Reach for it whenever a piece of
UI needs a label, a count, or a status indicator — "New", "Beta", "Live", "12", "Online" —
without competing with the surrounding content for attention.
Badge intentionally ships four variants (one more than Button) because count/numeric badges
benefit from a subtle look that ties them to a semantic role without shouting.
Overview
Variants
Pick by how much attention the label should claim on the page:
solid— opaque fill, contrast text. Use sparingly — status labels that need attention ("Critical", "Down").outline— transparent fill, colored border + text. Tag-like UI; ghosts well on colored backgrounds.soft— palette-subtlebackground + role-colored text. Default. The conventional "tag" look — calmly tagged, not loud.subtle— neutral background + muted text + role-colored dot/icon accent. Best for counts ("12") and numeric badges that shouldn't dominate.
Every variant works with every color and every theme variant (default / tetsu /
origami / katana) and adapts to the active platform. A Theme-Studio palette edit re-paints
the entire 28-cell grid automatically.
Anatomy
[ dot | leftIcon | label | rightIcon | × ][ dot | leftIcon | label | rightIcon | × ]Optional slots from left to right:
- dot —
withDotrenders a 6–8px palette-colored circle before the label. Mutually exclusive withleftIcon(the dot wins). Pair withdotPulsefor "Live" indicators. - leftIcon — any
ReactNode(commonly alucide-reacticon). - label — the children. The accessible name when
removableis set. - rightIcon — only renders when
removableisfalse. - × (remove button) —
removableswaps the right slot with a built-in keyboard-accessible<button>that firesonRemove.
Examples
Basic
Variants
Sizes
Colors
Shapes
WithDot
WithIcons
Removable
AsChild
CountAndStatus
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| asChild | boolean | — | Radix-style polymorphism. When `true`, Badge merges its props/className/ref onto the single child element (e.g. wrap an `<a>` to render as a styled link/notification badge). |
| color | ResponsiveValue<BadgeColor> | 'primary' | Semantic palette role driving fill / text / dot colors. |
| dotPulse | boolean | — | Animates the dot with a slow CSS pulse for "Live" / "Streaming" / "Active" indicators. Only applies when `withDot` is also true. Respects `prefers-reduced-motion`. |
| leftIcon | ReactNode | — | Element rendered before the label (e.g. a `lucide-react` icon). Ignored when `withDot`. |
| onRemove | (() => void) | — | Fires when the user clicks (or activates via keyboard) the built-in × button. |
| removable | boolean | — | Adds a built-in `<button>` after the label that fires `onRemove` when clicked. The button is keyboard-reachable and auto-derives `aria-label="Remove {children}"` when `children` is a string; consumers can override via `removeLabel`. |
| removeLabel | string | — | Explicit aria-label for the remove button. Required when `children` isn't a plain string (e.g. when wrapping JSX) — the engine `warn` fires if it's missing in that case. |
| rightIcon | ReactNode | — | Element rendered after the label. Ignored when `removable` (the × button takes that slot). |
| shape | enum | 'rounded' | Corner radius family. |
| size | ResponsiveValue<BadgeSize> | 'md' | Visual height + horizontal padding + font size. |
| sx | Sx | — | Theme-aware inline style object (resolves palette / spacing / radius tokens to CSS vars). |
| variant | ResponsiveValue<BadgeVariant> | 'soft' | Stylistic family. |
| withDot | boolean | — | Renders a tiny status dot before the label. Decorative — always `aria-hidden`. Mutually exclusive with `leftIcon` (the dot wins when both are passed). |
Accessibility
- Renders a
<span>by default — non-interactive content with no implicit role. Screen readers read the label inline with surrounding text. asChildpromotes the badge to whatever element the consumer passes (<a>,<button>, router<Link>) — full a11y semantics flow through viaSlot.withDotrenders a decorativearia-hiddencircle — it never appears in the accessible name.dotPulserespectsprefers-reduced-motion(the animation is suppressed viamotion-reduce:animate-none).removablerenders a real<button>with anaria-labelderived fromchildren(when it's a string) or from the explicitremoveLabelprop. Both Enter and Space activate it. A dev-mode warning fires whenremovableis set with non-string children and noremoveLabel.- Color contrast is verified for every variant × color cell at every size — including the
smallest (
smattext-[10px]).
Theming
Every visual decision is a palette token. Pull the <ThemeProvider> knobs to remix the whole
library, or scope overrides to Badge specifically:
<ThemeProvider
theme={defineTheme({
components: {
Badge: {
defaultProps: { variant: 'outline', shape: 'pill' },
styleOverrides: {
root: 'tracking-wide uppercase',
},
},
},
})}
>
{children}
</ThemeProvider><ThemeProvider
theme={defineTheme({
components: {
Badge: {
defaultProps: { variant: 'outline', shape: 'pill' },
styleOverrides: {
root: 'tracking-wide uppercase',
},
},
},
})}
>
{children}
</ThemeProvider>Instance-level overrides win via tailwind-merge:
<Badge className="font-bold" sx={{ radius: 'md' }} style={{ minWidth: 32 }}>
3
</Badge><Badge className="font-bold" sx={{ radius: 'md' }} style={{ minWidth: 32 }}>
3
</Badge>Do / Don't
- Do use
subtlefor inbox-style counts so the number doesn't dominate the nav. - Do pair
withDot dotPulsewithcolor="success"only when the indicator is actually live. Don't make every badge pulse — the motion stops being meaningful. - Don't use Badge as a button. Wrap an
<a>or<button>withasChildinstead so the element remains semantically interactive. - Don't stack more than three badges next to a single label — the eye stops grouping them after that.