Building a Badge Component — Variants, Sizes, and Dot Indicators

Badge is the first purely presentational primitive in this series — no form integration, no event handling, no focus management. That simplicity is deceptive. A Badge that looks right in isolation often breaks inside a nav item, a table cell, or an avatar. The decisions that make it composable are worth getting right now.
After six form field components, Badge is a welcome change of pace. It's small, it's visual, and it has no interactive behaviour of its own. But "simple" doesn't mean "trivial." A Badge needs to work inline with text, stacked on an avatar, tucked inside a nav item, and slotted into a table cell — all without introducing layout bugs.
The dot variant is the one most libraries skip or implement poorly. We're building it as a first-class variant, not an afterthought.
The four files
Badge.variants.ts
Badge.variants.ts
This is our first use of compoundVariants — CVA's way of applying classes only when a specific combination of variants is active. When dot is true, the size classes for height and width override the base h-2 w-2 with size-appropriate dimensions. Without compoundVariants, all dot badges would be the same size regardless of the size prop.
We also introduce two new intent tokens — success and warning — that we haven't needed until now. Add these to tokens.css:
And register them in @theme inline:
Badge.types.ts
Badge extends HTMLSpanElement — it's an inline element that sits inside other content. No forwardRef needed for a purely presentational component, but we'll add it for consistency with the rest of the library.
Badge.tsx
What each decision is doing
compoundVariants for dot sizing — a dot badge is a circle. Its width and height must be equal and scale with the size variant. CVA's compoundVariants lets us override the base dot dimensions per size combination. Without this, every dot badge would be the same size regardless of whether size="sm" or size="lg" is passed.
aria-hidden on dot badges — a dot indicator is purely decorative. It communicates status through color — green for online, red for error — but color alone is not accessible information. The dot itself is hidden from the accessibility tree with aria-hidden. The meaningful status information should be communicated through a screen-reader-only label on the parent element:
children is null for dot badges — a dot has no text content. Passing children to a dot badge would produce invisible text inside a tiny circle. We explicitly render null when dot is true to prevent accidental content from appearing.
<span> not <div> — Badge is an inline element. Using <div> would force it onto its own line inside flex or inline contexts, breaking most of the layouts it's used in. <span with inline-flex is the correct choice for a component that lives inside text, inside nav items, and alongside other inline elements.
The component index
src/components/Badge/index.ts:
Update the root entry point
Add to src/index.ts:
Usage
The decision rule
Use a dot Badge when color alone conveys the status and the parent element already has an accessible label. Use a text Badge when the status needs to be readable in isolation — inside a table, in a nav item, alongside a heading. Never rely on a dot Badge as the only indicator of critical state — color is not perceivable by all users and the dot carries no text for screen readers.
Up next: Building an Avatar component with fallback initials and image handling — the first component that manages an async state internally. Image loading, load errors, and the fallback chain from image to initials to icon all need to be handled gracefully.
Related: Building a Table component — sortable columns and row selection — Badge is one of the most common components inside a table cell. The Table article later in the series shows how Badge, Avatar, and other primitives compose inside a real data table.
Muhammad Athar is the founder of DesignDev.io and the engineer behind everything you read here. He writes about the decisions behind building real products — the components, the architecture, and the tradeoffs that don't make it into the tutorial.
Photo by Philip Oroni on Unsplash

