Badge
A small status indicator with multiple semantic variants.
Installation
Usage
import { Badge } from "@/components/ui/badge"Examples
Default
Variants
The six semantic variants. muted and outline draw a hairline edge instead of a fill, so they stay quiet beside a badge carrying a real status color.
Sizes
The three annotation sizes step 11 / 12 / 13px, each a step below the control type around it, so a badge reads as a label rather than a button. lg is the ceiling for a badge that annotates something.
Display
display is a different job, not the next step up: a featured stat in a chip on a marketing surface, like a subscriber count in a hero. It jumps to 16px, skipping body size. Keep it off control rows and card corners, where the 13px annotation ceiling holds.
With Icon
icon takes an element (<Star />), not a bare component reference. It scales with the badge size, so there is never a size to set on the icon itself.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "default" | "success" | "warning" | "destructive" | "muted" | "outline" | "default" | Semantic variants tint the background and text; outline renders bordered and transparent. |
| size | "sm" | "default" | "lg" | "display" | "default" | Scales padding, text, and icon size together: sm is 11px text, default 12px, lg 13px. Those three sit a step below the control type around them, so a badge reads as a label rather than a button. display (16px) is for a featured stat on a marketing surface, not for annotating controls. |
| icon | React.ReactNode | - | Optional icon element rendered before the label, e.g. <Star />. Scales with the badge size. |
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/registry/lib/utils"
const badgeVariants = cva(
// font-medium, not semibold: 600 is Button's weight, and a badge that matches
// a control's type treatment reads as a control. Radix Themes and shadcn both
// set badges at 500.
// `whitespace-nowrap` is structural, not cosmetic. A badge is a PILL: the
// `rounded-full` radius is half its height, so the moment the label wraps to
// two lines the radius doubles with it and both corners eat into the text,
// turning the chip into a blob with a clipped-looking label. It has no width
// of its own either, so a narrow parent (a table cell, a flex column) is
// enough to trigger it with no warning at the call site. A badge that will not
// fit should be shortened or truncated by its container; it must never wrap.
"inline-flex items-center whitespace-nowrap rounded-full border border-transparent font-medium transition-colors [&_svg]:shrink-0",
{
variants: {
variant: {
// Tinted variants read as LABELS, not controls: the text sits well past
// the tint (light -700 on -100, dark -300 on -900, both ~7:1) rather
// than at the -500 a `soft` Button uses. Darker-in-light and
// lighter-in-dark is what keeps a chip from looking clickable.
default: "bg-accent-100 dark:bg-accent-800 text-accent-700 dark:text-accent-400",
success: "bg-success-100 dark:bg-success-900 text-success-700 dark:text-success-300",
// Warning takes -800 in light, not -700: the hue is so light that
// warning-700 on warning-100 is only 3.64:1. -800 lands at 6.87:1,
// in the same band as the other tones' -700.
warning: "bg-warning-100 dark:bg-warning-900 text-warning-800 dark:text-warning-300",
destructive: "bg-destructive-100 dark:bg-destructive-900 text-destructive-700 dark:text-destructive-300",
muted: "bg-muted text-muted-foreground shadow-ring-flat",
outline: "shadow-ring-flat bg-transparent",
},
// The annotation sizes sit a step BELOW the control type they annotate:
// `default` is 12px where Button is 13px, and `lg` tops out at 13px so a
// badge never matches the body copy around it.
// Side padding steps 6 / 8 / 12 / 16 on the 4px grid. `lg` cannot take 8:
// that is `default`'s value, and two rungs with the same inset stop
// reading as a ramp, which matters more on a `rounded-full` pill where
// the corner eats the first pixels of the inset at both ends.
// `display` is a different job, not the next annotation step: a featured
// stat in a chip on a marketing surface (a subscriber count in a hero).
// It jumps to 16px, DELIBERATELY skipping 14: 14px is body copy, and a
// chip at body size reads as a sentence fragment that grew an edge. Above
// body it reads as an accent again. It is not for control rows or card
// corners, where the annotation ceiling (13px) still holds.
size: {
sm: "gap-0.5 px-1.5 py-0.5 text-2xs [&_svg]:size-2.5",
default: "gap-1 px-2 py-0.5 text-1xs [&_svg]:size-3",
lg: "gap-1.5 px-3 py-1 text-xs [&_svg]:size-3.5",
display: "gap-2 px-4 py-1.5 text-base [&_svg]:size-4",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof badgeVariants> {
icon?: React.ReactNode
}
// @use-when a small status or count marker attached to something else.
function Badge({ className, variant, size, icon, children, ...props }: BadgeProps) {
return (
<span data-slot="badge" className={cn(badgeVariants({ variant, size }), className)} {...props}>
{icon}
{children}
</span>
)
}
export { Badge, badgeVariants }