Badge
A small pill for counts and short labels attached to a control; values use Tag instead.
Payment runs
Betaimport { Badge } from "@oration/canon/components/badge";import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function Hero() { const [view, setView] = React.useState("approvals"); const views = [ { id: "approvals", label: "Your approvals", count: 4, unread: true }, { id: "exceptions", label: "Exceptions", count: 7, unread: false }, { id: "scheduled", label: "Scheduled runs", count: 2, unread: false }, { id: "history", label: "History", count: 0, unread: false }, ]; return ( <div className="flex w-full max-w-md flex-col gap-3 rounded-xl bg-card p-4 shadow-border"> <div className="flex items-center gap-2"> <h3 className="text-sm font-semibold text-foreground"> Payment runs </h3> <Badge variant="outline">Beta</Badge> </div> <nav aria-label="Payment run views" className="flex flex-col gap-0.5" > {views.map((item) => ( <button key={item.id} type="button" aria-current={view === item.id ? "page" : undefined} onClick={() => setView(item.id)} className={cn( "flex h-8 items-center gap-2 rounded-lg px-2 text-left text-13 text-muted-foreground outline-none transition-colors duration-150 hover:bg-muted hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/40", view === item.id && "bg-muted font-medium text-foreground", )} > <span className="min-w-0 flex-1 truncate"> {item.label} </span> {item.count > 0 ? ( <Badge variant={item.unread ? "default" : "secondary"} className="tabular-nums" > {item.count} <span className="sr-only"> {item.unread ? " waiting for you" : " runs"} </span> </Badge> ) : null} </button> ))} </nav> </div> );}Usage#
Badge is a small pill that annotates a control or an object with a count or a short qualifier: 4 approvals waiting, Beta, New, v3 live. It describes the thing it sits on; it is never the answer to a field. That is the line between Badge and Tag: a tag is a value (Stage is Negotiation), a badge is metadata (this view has 7 exceptions). Most badges are secondary or outline; the default variant is a solid Quiet Indigo fill, which belongs only to the viewer's own unread count.
When to use
- For a count on a nav item, tab or button: Exceptions 7, Your approvals 4.
- For a short qualifier beside a title: Beta, New, Strategic account.
- For a version or environment pointer that links somewhere, such as v3 live on a procedure, rendered as a
Link. - In the indigo default variant for the viewer's own unread or waiting count, and nowhere else.
- In the red tint for a count of failures that needs attention, such as 2 failed, with the word in the label.
When not to use
- For a select-option value such as a stage, tier or payment method. Use Tag
- For a run or health state such as Running, Paused or At risk. Use Status label
- For an active filter that can be removed. Use Filter chip
- For a keyboard shortcut in a menu or tooltip. Use Kbd
- For an action. A badge is not a button; if it goes somewhere, render it as a link. Use Button
The Quiet Indigo Rule
secondary.Badges annotate, tags answer
Anatomy#
- Container. A 20px pill (
rounded-4xl) with 8px side padding and a transparent 1px border that becomes the outline stroke or the focus edge. - Icon. Optional, forced to 12px.
data-icon="inline-start"or"inline-end"tightens that side to 6px. - Label. 12px at weight 500, one line,
whitespace-nowrap. Counts taketabular-nums.
Examples#
Variants
Secondary for counts, outline for qualifiers, the indigo default only for the viewer's own unread count, the red tint for a count of failures. Ghost and link exist for parity with Button and are rarely right.
import { Badge } from "@oration/canon/components/badge";export function Variants() { return ( <div className="grid grid-cols-3 gap-x-10 gap-y-5 sm:grid-cols-6"> {( [ { variant: "secondary", label: "12" }, { variant: "outline", label: "Beta" }, { variant: "default", label: "4" }, { variant: "destructive", label: "2 failed" }, { variant: "ghost", label: "Draft" }, { variant: "link", label: "View all" }, ] as const ).map((item) => ( <div key={item.variant} className="flex flex-col items-start gap-2" > <Badge variant={item.variant} className="tabular-nums"> {item.label} </Badge> <span className="font-mono text-xs text-muted-foreground"> {item.variant} </span> </div> ))} </div> );}Badge or tag
A tag answers a field: Lifecycle is Customer. A badge annotates the thing itself: this company is a strategic account, and it has 18 open invoices.
Northwind Freight
Strategic account- Lifecycle
- Customer
- Payment terms
- Net 30
- Open invoices
- $184,220.0018
import { Badge } from "@oration/canon/components/badge";import { Tag } from "@oration/canon/components/tag";export function BadgeOrTag() { return ( <div className="flex w-full max-w-md flex-col gap-3 rounded-xl bg-card p-4 shadow-border"> <div className="flex items-center gap-2"> <h3 className="text-sm font-semibold text-foreground"> Northwind Freight </h3> <Badge variant="outline">Strategic account</Badge> </div> <dl className="grid grid-cols-[7rem_1fr] items-center gap-y-2 text-13"> <dt className="text-muted-foreground">Lifecycle</dt> <dd> <Tag color="green">Customer</Tag> </dd> <dt className="text-muted-foreground">Payment terms</dt> <dd> <Tag>Net 30</Tag> </dd> <dt className="text-muted-foreground">Open invoices</dt> <dd className="flex items-center gap-2"> <span className="font-medium tabular-nums"> $184,220.00 </span> <Badge variant="secondary" className="tabular-nums"> 18 </Badge> </dd> </dl> </div> );}In a control
A count or a New marker sits after the label inside the button or tab, and disappears when the count reaches zero. Click Exceptions to resolve one.
import { Badge } from "@oration/canon/components/badge";import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function InControls() { const [open, setOpen] = React.useState(7); return ( <div className="flex flex-wrap items-center gap-3"> <Button type="button" variant="outline" onClick={() => { setOpen((count) => Math.max(0, count - 1)); toast.add({ title: "Resolved one exception", description: "Northwind Freight, INV-20417 matched to PO-20931.", }); }} > Exceptions {open > 0 ? ( <Badge variant="secondary" className="tabular-nums"> {open} </Badge> ) : null} </Button> <Button type="button" variant="ghost" size="sm"> Remittance templates <Badge variant="outline">New</Badge> </Button> </div> );}With icons
Icons are 12px. Mark them data-icon="inline-start" or "inline-end" so the padding on that side tightens from 8px to 6px.
import { Badge } from "@oration/canon/components/badge";import { ArrowUpRightIcon, SparklesIcon } from "lucide-react";export function WithIcons() { return ( <> <Badge variant="secondary"> <SparklesIcon data-icon="inline-start" aria-hidden="true" /> AI drafted </Badge> <Badge variant="outline"> v3 live <ArrowUpRightIcon data-icon="inline-end" aria-hidden="true" /> </Badge> </> );}As a link
Render a Link through render when the badge goes somewhere, such as the live version of a procedure. It keeps link semantics and gains a hover tint and a focus ring.
import { Badge } from "@oration/canon/components/badge";import { ArrowUpRightIcon } from "lucide-react";import Link from "next/link";export function AsLink() { return ( <div className="flex items-center gap-2 text-13"> <span className="text-foreground"> Procedure: vendor onboarding </span> <Badge variant="outline" render={<Link href="/design/components/tag" />} > v3 live <ArrowUpRightIcon data-icon="inline-end" aria-hidden="true" /> </Badge> </div> );}States#
import { Badge } from "@oration/canon/components/badge";export function StatesMatrix() { const variants = [ "secondary", "outline", "default", "destructive", ] as const; const forced: Record< (typeof variants)[number], { Rest: string; Hover: string; Focus: string } > = { secondary: { Rest: "", Hover: "bg-secondary/80", Focus: "border-ring ring-[3px] ring-ring/50", }, outline: { Rest: "", Hover: "bg-muted text-muted-foreground", Focus: "border-ring ring-[3px] ring-ring/50", }, default: { Rest: "", Hover: "bg-primary/80", Focus: "border-ring ring-[3px] ring-ring/50", }, destructive: { Rest: "", Hover: "bg-destructive/20", Focus: "border-ring ring-[3px] ring-destructive/20", }, }; const states = ["Rest", "Hover", "Focus"] as const; return ( <div className="grid w-full max-w-lg grid-cols-[6rem_repeat(3,minmax(0,1fr))] items-center gap-x-2 gap-y-3"> <span /> {states.map((state) => ( <span key={state} className="text-center text-xs text-muted-foreground" > {state === "Rest" ? "Rest" : `${state} (as a link)`} </span> ))} {variants.map((variant) => ( <div key={variant} className="contents"> <span className="text-13 text-muted-foreground capitalize"> {variant === "default" ? "Default" : variant} </span> {states.map((state) => ( <div key={state} className="flex justify-center"> <Badge variant={variant} className={forced[variant][state]} > v3 live </Badge> </div> ))} </div> ))} </div> );}| State | Treatment |
|---|---|
| Rest | The variant's fill: secondary is Quiet Fill, outline a hairline stroke, default Quiet Indigo, destructive a 10% red tint (20% in dark). |
| Hover | Only when rendered as a link: default and secondary fade to 80%, destructive deepens to a 20% tint, outline fills Well Gray. Ghost fills Well Gray on hover as any element. 150ms. |
| Focus visible | Only when focusable, as a link: an indigo border and a 3px ring at 50%. Destructive uses a red ring at 20%. |
| Invalid | With aria-invalid, a red border and a red ring at 20%. Rarely meaningful on a badge. |
| Zero | Not drawn. Remove the badge when a count reaches zero rather than showing 0. |
Behavior#
- Badge renders a
<span data-slot="badge">through Base UIuseRender, sorendercan swap in another element such as<Link />while keeping the badge classes. - Props merge with
mergeProps, so event handlers and classes you pass are combined with the badge's own. - It never grows past one line:
whitespace-nowrapandoverflow-hiddenkeep it at 20px. Keep labels short rather than relying on clipping. - Badge is static. It has no pressed or selected state and doesn't animate when a count changes; if the number moves, the surrounding control can use Animated number.
badgeVariants({ variant })returns the class string for an element that can't be a Badge.
Do and don't#
Content#
- Counts are figures in tabular numerals: 7, 99+. Cap at 99+ in navigation.
- Qualifiers are one or two words in sentence case: Beta, New, Strategic account. Never uppercase.
- Say what the number counts when it isn't obvious from the control: 2 failed, not a bare red 2.
- No punctuation, no emoji and no status words that belong in a Status label, such as Running or Paused.
Accessibility#
- A badge is a
<span>with no role; screen readers read its text as part of the control it sits in. A bare number needs context, so add sr-only text such as<span className="sr-only"> waiting for you</span>or name the parent control. - Mark icons inside a badge
aria-hidden="true"; the label carries the meaning. - Red and indigo counts must still make sense in grayscale, so the words (2 failed) carry the meaning, not the tint.
- A badge rendered as a link is a real anchor: it takes focus, shows the 3px focus ring and is announced as a link. At 20px tall it is under the 24px minimum hit size, so keep it inside a larger clickable row or give it padding around.
- Don't announce every count change. If a count matters in real time, put a polite live region on the surrounding status text, not on the badge.
Design tokens#
| Token | Used for |
|---|---|
--secondary | Secondary fill |
--secondary-foreground | Secondary text |
--primary | Default fill (viewer's unread count only) |
--primary-foreground | Default text |
--destructive | Destructive text and its 10% and 20% tints |
--border | Outline stroke |
--muted | Hover fill of outline and ghost |
--ring | Focus border and 3px ring at 50% |
--radius-4xl | Pill corners (26px on a 20px box) |
text-xs | 12px label at weight 500 |
API reference#
Badge
A count or qualifier pill, built on Base UI useRender. Also exported: badgeVariants.
Other props spread onto <span> (via useRender.ComponentProps<"span">).
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "secondary" | "destructive" | "outline" | "ghost" | "link" | "default" | Visual weight. Pass secondary or outline for almost every badge; default is solid indigo. |
render | ReactElement | (props, state) => ReactElement | No default | Render as another element, such as <Link href />. The state passed to a render function is { slot: "badge", variant }. |
className | string | No default | Merged after the variant classes. |
badgeVariants
The class recipe behind Badge, from cva.
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "secondary" | "destructive" | "outline" | "ghost" | "link" | null | "default" | Visual weight. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
The default variant is a solid Quiet Indigo fill, so an unconfigured <Badge> breaks The Quiet Indigo Rule. Every badge except the viewer's unread count should pass variant="secondary" or "outline"; the default should arguably be secondary.
DESIGN.md doesn't define a badge. Its pill corners (rounded-4xl) sit outside the 6, 8, 10 and 12px steps and the listed round elements, and its 12px medium label matches Label type only by coincidence.
Only one product file imports Badge (the procedure editor header). The contact center queue hand-rolls its unread count as a 16px indigo pill at 11px, and settings use Tags for qualifiers such as Default and System.
ghost has no fill at rest, so it reads as plain text, and link is a text link in a badge's box. Both mirror Button's variants rather than a badge need.