Badge
A small capsule for a count, a label or a state.
Vibra's default badge reads --brand and hovers by mixing ink into the fill rather than fading it to 80%; destructive is the danger tone on its opaque tint in both modes, and a linked one marks hover with its border instead of a deeper fill. Focus is the kit's solid accent outline, and the padding beside an icon sits on the inline axis, so the icon keeps it in a right-to-left page. For a status word with a tone per state, reach for StatusBadge; for removable tags, TagList; for a delta, MetricDelta.
Install
npx shadcn@latest add @vibra/badgeNeeds the @vibra registry in your components.json — set it up once.
Examples
import { BadgeCheckIcon } from "lucide-react"
import { Badge } from "@/components/ui/badge"
export default function BadgeDemo() {
return (
<div className="flex flex-wrap items-center justify-center gap-2">
<Badge>Pro plan</Badge>
<Badge variant="secondary">Beta</Badge>
<Badge variant="outline">
<BadgeCheckIcon data-icon="inline-start" aria-hidden="true" />
Verified domain
</Badge>
<Badge variant="destructive">Overdue</Badge>
<Badge variant="outline" className="font-mono">
v2.14.0
</Badge>
<Badge variant="secondary" className="tabular-nums">
12 seats
</Badge>
</div>
)
}With an icon
data-icon="inline-start" tightens the padding on the icon's side; the word carries the meaning.
import { CalendarClockIcon, LockIcon, ShieldCheckIcon } from "lucide-react"
import { Badge } from "@/components/ui/badge"
// A workspace's settings at a glance. data-icon="inline-start" tightens the
// padding on the icon's side, so the glyph and the word sit centred in the
// capsule; the icon is decoration, the word carries the meaning.
export default function BadgeWithIcon() {
return (
<div className="flex flex-wrap items-center justify-center gap-2">
<Badge variant="outline">
<ShieldCheckIcon data-icon="inline-start" aria-hidden="true" />
SSO enforced
</Badge>
<Badge variant="outline">
<LockIcon data-icon="inline-start" aria-hidden="true" />
Read only
</Badge>
<Badge variant="secondary">
<CalendarClockIcon data-icon="inline-start" aria-hidden="true" />
Renews Oct 1
</Badge>
</div>
)
}Counts
Caps at 99+ so the column keeps its width; a screen reader hears the whole figure and what it counts.
import { Badge } from "@/components/ui/badge"
const FOLDERS = [
{ label: "Inbox", count: 12, unit: "unread" },
{ label: "Mentions", count: 3, unit: "unread" },
{ label: "Approvals", count: 128, unit: "waiting" },
]
// A count caps at 99+ so the column keeps its width, and a single digit
// stays a circle. The badge shows the short form; a screen reader hears the
// whole figure and what it counts.
export default function BadgeCount() {
return (
<ul className="flex w-full max-w-56 flex-col gap-0.5">
{FOLDERS.map((folder) => (
<li key={folder.label} className="flex items-center justify-between gap-3 rounded-md px-2 py-1.5 text-sm">
{folder.label}{" "}
<Badge variant="secondary" className="min-w-5 px-1.5 tabular-nums">
<span aria-hidden="true">{folder.count > 99 ? "99+" : folder.count}</span>
<span className="sr-only">
{folder.count} {folder.unit}
</span>
</Badge>
</li>
))}
</ul>
)
}As a link
render swaps the span for an anchor and keeps the look, the focus outline included.
import { ArrowRightIcon } from "lucide-react"
import { Badge } from "@/components/ui/badge"
// render swaps the span for an anchor and keeps the look. A linked badge
// takes the focus outline, and marks its hover with the border or a deeper
// fill rather than an underline.
export default function BadgeLink() {
return (
<div className="flex flex-wrap items-center justify-center gap-2">
<Badge variant="outline" render={<a href="/changelog#v2-14" />}>
v2.14 is out
<ArrowRightIcon data-icon="inline-end" aria-hidden="true" className="rtl:rotate-180" />
</Badge>
<Badge render={<a href="/billing/plans" />}>Pro plan</Badge>
</div>
)
}Beside a title
Qualifies what the title names: a feature in beta, or one the plan has to include.
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
// Beside a title, a badge qualifies the thing the title names: a feature
// still in beta, or one the plan has to include. It centres on the title's
// line and never pushes the title onto two.
export default function BadgeInTitle() {
return (
<div className="flex w-full max-w-md flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<p className="text-base font-semibold">Revenue forecast</p>
<Badge variant="secondary">Beta</Badge>
</div>
<div className="flex items-center justify-between gap-4 panel p-3">
<div className="flex min-w-0 flex-col gap-0.5">
<p className="flex items-center gap-2 text-sm font-medium">
Custom domain <Badge>Pro plan</Badge>
</p>
<p className="text-xs text-muted-foreground">Share reports from reports.example.com.</p>
</div>
<Button variant="outline" size="sm" className="shrink-0">
Upgrade
</Button>
</div>
</div>
)
}In a table
A plan is a category, so it is a Badge; a status with a tone per value is StatusBadge.
import { Badge } from "@/components/ui/badge"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
const ACCOUNTS = [
{ name: "Alder Robotics", plan: "Enterprise", seats: 120, trial: false },
{ name: "Blue Harbor Logistics", plan: "Team", seats: 24, trial: false },
{ name: "Wavelength Health", plan: "Starter", seats: 5, trial: true },
]
// A plan is a category, not a state, so it takes Badge — a status with a
// tone per value is StatusBadge's job. The fill steps down with the plan; a
// second label sits beside the first, and wraps under it on a phone rather
// than widening the table.
export default function BadgeInTable() {
return (
<Table className="w-full max-w-md">
<TableHeader>
<TableRow>
<TableHead>Account</TableHead>
<TableHead>Plan</TableHead>
<TableHead className="text-right">Seats</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ACCOUNTS.map((account) => (
<TableRow key={account.name}>
<TableCell className="font-medium whitespace-normal">{account.name}</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-1">
<Badge
variant={account.plan === "Enterprise" ? "default" : account.plan === "Team" ? "secondary" : "outline"}
>
{account.plan}
</Badge>
{account.trial ? <Badge variant="secondary">Trial</Badge> : null}
</div>
</TableCell>
<TableCell className="text-right tabular-nums">{account.seats}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}Marks the default
One row marked apart from the rest; every other row offers to become it.
import { CreditCardIcon, LandmarkIcon } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
const METHODS = [
{ id: "card", label: "Card ending 0187", detail: "Expires 05/27", icon: CreditCardIcon, isDefault: true },
{ id: "bank", label: "Bank account ending 2291", detail: "Verified Aug 12", icon: LandmarkIcon, isDefault: false },
]
// One row of a list marked apart from the rest. The badge says which method
// invoices charge; every other row offers to become it.
export default function BadgeDefault() {
return (
<ul className="flex w-full max-w-md flex-col divide-y divide-border panel">
{METHODS.map((method) => (
<li key={method.id} className="flex items-center gap-3 px-3 py-2.5">
<method.icon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
<div className="flex min-w-0 flex-1 flex-col">
<p className="flex items-center gap-2 text-sm font-medium">
<span className="truncate">{method.label}</span>
{method.isDefault ? <Badge variant="secondary">Default</Badge> : null}
</p>
<p className="text-xs text-muted-foreground">{method.detail}</p>
</div>
{method.isDefault ? null : (
<Button variant="ghost" size="xs" className="shrink-0">
Make default{" "}
<span className="sr-only">for {method.label}</span>
</Button>
)}
</li>
))}
</ul>
)
}Pinned to a card
Across the card's top edge, labelling the card without taking a line of its content.
import { Badge } from "@/components/ui/badge"
const PLANS = [
{ name: "Team", price: "$12", note: "Up to 10 seats", popular: false },
{ name: "Pro", price: "$24", note: "Up to 50 seats, SSO", popular: true },
]
// Pinned across a card's top edge, a badge labels the card as a whole
// without taking a line of its content. The grid leaves room above for it.
export default function BadgeCard() {
return (
<div className="grid w-full max-w-md grid-cols-2 gap-3 pt-2.5">
{PLANS.map((plan) => (
<div key={plan.name} className="relative flex flex-col gap-1 panel p-4 pt-5">
{plan.popular ? <Badge className="absolute -top-2.5 start-3">Most popular</Badge> : null}
<p className="text-sm font-medium">{plan.name}</p>
<p className="text-sm">
<span className="type-numeral text-2xl">{plan.price}</span>{" "}
<span className="text-muted-foreground">a seat, monthly</span>
</p>
<p className="text-xs text-muted-foreground">{plan.note}</p>
</div>
))}
</div>
)
}With a face
A person as a chip. The face is decoration beside the printed name, so it is hidden whole.
import { getInitials } from "@/lib/format"
import { avatarFor } from "@/lib/avatars"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
const REVIEWERS = ["Hiro Tanaka", "Saoirse Byrne", "Joaquín Ortega"]
// A person as a chip: the face tucked into the capsule's start, the name
// beside it. The face is decoration next to the printed name, so it is hidden
// whole — initials included — and the chip reads as the name alone.
export default function BadgeAvatar() {
return (
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">Reviewers</span>
{REVIEWERS.map((name) => (
<Badge key={name} variant="outline" className="h-6 gap-1.5 ps-0.5 pe-2 text-label">
<Avatar aria-hidden="true" className="size-5">
<AvatarImage src={avatarFor(name)} alt="" />
<AvatarFallback className="text-avatar">{getInitials(name)}</AvatarFallback>
</Avatar>
{name}
</Badge>
))}
</div>
)
}In progress
A spinner in the icon's place that stops under reduced motion; the word says what is running.
import { Badge } from "@/components/ui/badge"
import { Spinner } from "@/components/ui/spinner"
// Work in flight, labelled where it happens: the spinner takes the icon's
// place and stops under reduced motion, and the word says what is running.
// The spinner is hidden — the word is what a screen reader needs.
export default function BadgeLoading() {
return (
<div className="flex flex-wrap items-center justify-center gap-2">
<Badge variant="secondary">
<Spinner data-icon="inline-start" aria-hidden="true" />
Syncing
</Badge>
<Badge variant="outline" className="tabular-nums">
<Spinner data-icon="inline-start" aria-hidden="true" />
Importing 1,204 rows
</Badge>
</div>
)
}Long label
Truncates inside a span at the width it is given; the whole name stays in the text.
import { Badge } from "@/components/ui/badge"
const SEGMENT = "EMEA enterprise accounts renewing in Q4"
// A segment's name is whatever the team called it. The badge takes the
// width it is given and truncates inside a span; the whole name stays in the
// text a screen reader reads, and the title hands it to the pointer.
export default function BadgeLongLabel() {
return (
<div className="flex w-full max-w-56 flex-col gap-1.5 panel p-3">
<p className="text-sm font-medium">Blue Harbor Logistics</p>
<Badge variant="outline" className="max-w-full" title={SEGMENT}>
<span className="truncate">{SEGMENT}</span>
</Badge>
</div>
)
}With an explanation
Rendered as a button so the keyboard reaches its tooltip, and described by the same sentence for a screen reader.
import { Badge } from "@/components/ui/badge"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
const FACTS = [
{ term: "Data region", badge: "EU", note: "Stored in Frankfurt (eu-central-1). It never leaves the EU." },
{ term: "Plan", badge: "Pro", note: "50 seats, two years of history and SSO." },
]
// A terse badge that needs a sentence: rendered as a button, so the keyboard
// reaches it and focus opens the tooltip. A tooltip is for the pointer, so
// the same sentence is also the button's description for a screen reader.
export default function BadgeTooltip() {
return (
<dl className="grid grid-cols-[auto_auto] items-center gap-x-4 gap-y-2 text-sm">
{FACTS.map((fact) => (
<div key={fact.term} className="contents">
<dt className="text-muted-foreground">{fact.term}</dt>
<dd>
<Tooltip>
<TooltipTrigger
render={
<Badge
variant="outline"
render={<button type="button" aria-describedby={`badge-tooltip-${fact.badge}`} />}
/>
}
>
{fact.badge}
</TooltipTrigger>
<TooltipContent>{fact.note}</TooltipContent>
</Tooltip>
<span id={`badge-tooltip-${fact.badge}`} className="sr-only">
{fact.note}
</span>
</dd>
</div>
))}
</dl>
)
}Announcement
An outline badge as the link, carrying a filled one for the word that matters.
import { ArrowRightIcon } from "lucide-react"
import { Badge } from "@/components/ui/badge"
// The line above a dashboard's first card when something ships: an outline
// badge rendered as the link, carrying a filled one for the word that
// matters. One link, so one tab stop and one name.
export default function BadgeAnnouncement() {
return (
<Badge variant="outline" render={<a href="/changelog#scheduled-exports" />} className="h-7 gap-2 ps-1 text-label">
<Badge>New</Badge> Scheduled exports are here
<ArrowRightIcon data-icon="inline-end" aria-hidden="true" className="text-muted-foreground rtl:rotate-180" />
</Badge>
)
}Right to left
The same badges in Arabic: each icon keeps its tighter padding, and the arrow turns.
import { ArrowRightIcon, LockIcon } from "lucide-react"
import { Badge } from "@/components/ui/badge"
function Labels({ readOnly, whatsNew }: { readOnly: string; whatsNew: string }) {
return (
<div className="flex flex-wrap items-center justify-center gap-2">
<Badge variant="outline">
<LockIcon data-icon="inline-start" aria-hidden="true" />
{readOnly}
</Badge>
<Badge variant="secondary" render={<a href="/changelog" />}>
{whatsNew}
<ArrowRightIcon data-icon="inline-end" aria-hidden="true" className="rtl:rotate-180" />
</Badge>
</div>
)
}
// The same two badges in English and in Arabic. The tighter padding sits on
// each icon's own side because data-icon names the inline start and end, and
// the arrow turns with the reading direction.
export default function BadgeRtl() {
return (
<div className="flex flex-col items-center gap-3">
<Labels readOnly="Read only" whatsNew="What’s new" />
<div dir="rtl" lang="ar">
<Labels readOnly="للقراءة فقط" whatsNew="ما الجديد" />
</div>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "default" | "secondary" | "destructive" | "outline" | "ghost" | "link" | "default" | default is the accent fill; destructive the danger tone on its tint; outline a hairline. |
| render | React.ReactElement | — | Renders the badge as another element — a link, say — keeping its look. |
Dependencies
Source
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
// The tighter padding beside an icon sits on the inline axis (ps/pe), so it
// follows the icon to the right-hand side of a right-to-left page.
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[background-color,border-color,color] duration-(--duration-fast) ease-(--ease-standard) focus-ring has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 aria-invalid:border-destructive [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default:
"bg-brand text-brand-foreground [a]:hover:bg-[color-mix(in_oklch,var(--brand),var(--foreground)_10%)]",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
// Same fill in both modes, and a linked badge marks its hover with the
// border it already reserves room for: deepening the tint would move
// the background towards the text it is under.
destructive: "bg-danger-muted text-danger [a]:hover:border-danger",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost: "hover:bg-muted hover:text-muted-foreground",
link: "text-brand underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }