Skip to content

Badge

A small pill for counts and short labels attached to a control; values use Tag instead.

Status
Beta
Level
Atom
Category
Data display
Adoption
Not used yet
import { Badge } from "@oration/canon/components/badge";
packages/canon/src/components/badge.tsx

Payment runs

Beta
import { 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

Indigo is spent on the view's one filled primary action, on selection, on focus, on labelled live state, and on the viewer's own markers such as an unread count. A badge is indigo only when it counts something waiting for you; every other count is secondary.

Badges annotate, tags answer

If the pill is the value of a field, it is a Tag in the option's hue. If it describes the control or record it sits on, it is a Badge in neutral. Never draw a field value as a badge or a count as a tag.

Anatomy#

AI drafted
  1. Container. A 20px pill (rounded-4xl) with 8px side padding and a transparent 1px border that becomes the outline stroke or the focus edge.
  2. Icon. Optional, forced to 12px. data-icon="inline-start" or "inline-end" tightens that side to 6px.
  3. Label. 12px at weight 500, one line, whitespace-nowrap. Counts take tabular-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.

12secondary
Betaoutline
4default
2 faileddestructive
Draftghost
View alllink
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.

AI draftedv3 live
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>        </>    );}

States#

RestHover (as a link)Focus (as a link)
secondary
v3 live
v3 live
v3 live
outline
v3 live
v3 live
v3 live
Default
v3 live
v3 live
v3 live
destructive
v3 live
v3 live
v3 live
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>    );}
States
StateTreatment
RestThe variant's fill: secondary is Quiet Fill, outline a hairline stroke, default Quiet Indigo, destructive a 10% red tint (20% in dark).
HoverOnly 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 visibleOnly when focusable, as a link: an indigo border and a 3px ring at 50%. Destructive uses a red ring at 20%.
InvalidWith aria-invalid, a red border and a red ring at 20%. Rarely meaningful on a badge.
ZeroNot drawn. Remove the badge when a count reaches zero rather than showing 0.

Behavior#

  • Badge renders a <span data-slot="badge"> through Base UI useRender, so render can 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-nowrap and overflow-hidden keep 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#

Your approvals4
Exceptions7
Scheduled runs2
Do. Keep counts in secondary and save the indigo fill for the one count that is waiting on the viewer.
Your approvals4
Exceptions7
Scheduled runs2
Don't. Fill every count with indigo. It spends the accent on data and competes with the primary button for attention.
StageNegotiation
Do. Show a field value as a Tag in its option hue.
StageNegotiation
Don't. Draw a stage or tier as a badge. It loses the option's hue and reads as a note about the row instead of its value.
Exceptions99+
History
Do. Hide the badge at zero and cap large counts at 99+.
Exceptions1,284
History0
Don't. Show 0, or a four-digit count that widens the nav item and says nothing more than 99+ would.

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#

Design tokens
TokenUsed for
--secondarySecondary fill
--secondary-foregroundSecondary text
--primaryDefault fill (viewer's unread count only)
--primary-foregroundDefault text
--destructiveDestructive text and its 10% and 20% tints
--borderOutline stroke
--mutedHover fill of outline and ghost
--ringFocus border and 3px ring at 50%
--radius-4xlPill corners (26px on a 20px box)
text-xs12px 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">).

Props of Badge
PropTypeDefaultDescription
variant"default" | "secondary" | "destructive" | "outline" | "ghost" | "link""default"Visual weight. Pass secondary or outline for almost every badge; default is solid indigo.
renderReactElement | (props, state) => ReactElementNo defaultRender as another element, such as <Link href />. The state passed to a render function is { slot: "badge", variant }.
classNamestringNo defaultMerged after the variant classes.

badgeVariants

The class recipe behind Badge, from cva.

Props of badgeVariants
PropTypeDefaultDescription
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.