FoundationsBadge
A 10px uppercase mono mark for status and category.
shippeddraftnewdeprecated
The thinking
A badge exists so a piece of state — shipped, draft, deprecated — can ride alongside content without competing with it for reading attention. The one load-bearing decision is that it isn't text: it's a colored chip you recognize by surface before you read the word, which is why the whole thing is built from three background/foreground token pairs (
--secondary, --accent, --destructive) plus a border-only outline, rather than from typography alone. That framing is also what licenses the rest: the uppercase, font-mono, 10px treatment would be unreadable for prose, but a badge is never asked to be read at length, so the cost never comes due. I gave it rounded-sm and leading-[1.8] so the chip sits as a small object with air around the caps, not a label crammed into a box — the detail that separates a mark from a default <span> with a fill.Interface design
Tags are the sanctioned exception to the 14px floor: 10px all caps, mono —
font-mono text-[10px] uppercase in the base recipe, padded into a chip by px-1.5 py-px. A badge is scanned as a mark, not read as text, so the rules for reading at length don't apply; the uppercase mono treatment is what buys the exception, and lowercase prose at 10px would be a violation, not a badge. The variants split three filled pairs — bg-secondary for ambient state, bg-accent for the one worth noticing, bg-destructive for the warning — from an outline that drops to text-muted-foreground, the quietest rung, for metadata that shouldn't pull the eye. One structural detail: every variant carries border border-transparent, so outline recolors a border that was always there with border-border instead of adding one and nudging the layout. If it ever needs a sentence, it isn't a badge anymore.Interaction
A badge doesn't move. There is no hover style, no press scale, no transition anywhere in the source — and that is the whole interaction design, because a badge solicits no intent and so has none to infer. Nobody hovers a status mark expecting an answer, and motion on an inert element is a false promise: a chip that stirs under the pointer reads as clickable, and a status mark that invites a click is lying. The interactive things around it earn their motion by responding to you. The badge earns its place by never pretending to.
Sound design
Silent, and it doesn't even have to say so. The global SoundLayer plays the tap through one capturing click listener, but only for elements that carry an interactive role —
a[href], button, [role="button"] and the rest of that list — and a badge is a plain <span> with no role, so the selector never matches it. data-silent exists for components that would match but own a paired sound of their own; the badge needs no opt-out because it was never opted in. Silence by semantics, not by exception.Empathy
There is no disabled state, no focus ring, no reduced-motion branch — nothing here to disable, focus, or animate, so those chaos cases resolve to nothing by construction. What remains is how the word survives. The element is a role-less
<span>, so a screen reader gets the word as inline text in the reading flow, not as a widget; and because the caps come from the uppercase class rather than capitalized source text, the DOM keeps the word's real casing instead of shipping shouted letters. I also keep the word doing the meaning-work — the destructive red is redundant with deprecated, never a substitute for it, so the mark survives without its color. Overflow gets no special handling: the chip simply grows with its content, and a badge long enough to need truncating was mislabeled, not overflowing.import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
// tags are the sanctioned exception to the 14px floor: 10px all caps
const badgeVariants = cva(
"inline-flex items-center rounded-sm border border-transparent px-1.5 py-px font-mono text-[10px] font-normal uppercase leading-[1.8]",
{
variants: {
variant: {
default: "bg-secondary text-secondary-foreground",
outline: "border-border bg-transparent text-muted-foreground",
accent: "bg-accent text-accent-foreground",
destructive: "bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof badgeVariants> {
ref?: React.Ref<HTMLSpanElement>;
}
const Badge = ({ ref, className, variant, ...props }: BadgeProps) => (
<span
ref={ref}
className={cn(badgeVariants({ variant, className }))}
{...props}
/>
);
Badge.displayName = "Badge";
export { Badge, badgeVariants };