Skip to content

Badge

A short, non-interactive label that tags an object with a status, category or count.

SourceRegistry
OperationalNewv1.0.0Draft
import { Badge, BadgeDot } from "@/components/ui/badge"

export default function BadgeDemo() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-2">
      <Badge variant="success">
        <BadgeDot />
        Operational
      </Badge>
      <Badge variant="brand">New</Badge>
      <Badge variant="secondary">v1.0.0</Badge>
      <Badge variant="outline">Draft</Badge>
    </div>
  )
}

Anatomy

OperationalDocs
  1. 1DotOptional. A status dot in the badge's color that can pulse for ongoing states.
  2. 2ContainerA small label with rounded corners. Semantic variants tint it with the status colors; outline adds a border instead of a fill.
  3. 3LabelA word or two in sentence case: a status, a count or a category.
  4. 4IconOptional. Sized by the badge, which tightens its padding on that side.

Installation

$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/badge.json

Usage

import { Badge, BadgeDot } from "@/components/ui/badge"
<Badge variant="success">
  <BadgeDot />
  Paid
</Badge>

Examples

Variants

Variants map to meaning, not to taste. The four status variants — success, warning, destructive, info — are reserved for state, so a green badge always means the same thing everywhere in a product.

DefaultSecondaryOutlineBrandBrand secondarySuccessWarningDestructiveInfoGhostLink
import { Badge } from "@/components/ui/badge"

export default function BadgeVariants() {
  return (
    <div className="flex max-w-lg flex-wrap items-center justify-center gap-2">
      <Badge>Default</Badge>
      <Badge variant="secondary">Secondary</Badge>
      <Badge variant="outline">Outline</Badge>
      <Badge variant="brand">Brand</Badge>
      <Badge variant="brand-secondary">Brand secondary</Badge>
      <Badge variant="success">Success</Badge>
      <Badge variant="warning">Warning</Badge>
      <Badge variant="destructive">Destructive</Badge>
      <Badge variant="info">Info</Badge>
      <Badge variant="ghost">Ghost</Badge>
      <Badge variant="link">Link</Badge>
    </div>
  )
}
VariantUse it for
defaultHigh-emphasis labels that must stand out on busy surfaces. Use sparingly.
secondaryNeutral metadata: versions, file types, roles, counts.
outlineNeutral categories and tags that should recede.
brandHighlights such as New or Beta, and the one featured option in a set.
brand-secondaryA plan or tier, such as Pro, in your secondary brand color. Monochrome until a theme sets it.
successCompleted, healthy or approved states: Paid, Operational, Merged.
warningStates that need attention soon: Pending, Degraded, Expiring.
destructiveFailures and blocking states: Failed, Outage, Overdue.
infoNeutral, informative states: Maintenance, Scheduled, Draft review.
ghost, linkBadges inside dense rows or text that should look like metadata until hovered.

Sizes

default (20px) fits alongside body text and in table cells. Use sm for counters inside navigation and lg for badges that sit next to headings.

SmallDefaultLarge
import { Badge } from "@/components/ui/badge"

export default function BadgeSizes() {
  return (
    <div className="flex items-center gap-3">
      <Badge variant="brand" size="sm">
        Small
      </Badge>
      <Badge variant="brand">Default</Badge>
      <Badge variant="brand" size="lg">
        Large
      </Badge>
    </div>
  )
}

Status

Pair status badges with BadgeDot. The dot makes a column of states scannable at a glance; the word makes the state unambiguous. Set live to pulse the dot for activity in progress, such as a build or a sync. A steady state keeps a still dot, however important it is. The pulse stops when reduced motion is on.

  • APIOperational
  • DashboardDegraded
  • WebhooksOutage
  • Search indexReindexing
import { Badge, BadgeDot } from "@/components/ui/badge"

const services = [
  { name: "API", status: "Operational", variant: "success", live: false },
  { name: "Dashboard", status: "Degraded", variant: "warning", live: false },
  { name: "Webhooks", status: "Outage", variant: "destructive", live: false },
  { name: "Search index", status: "Reindexing", variant: "info", live: true },
] as const

export default function BadgeStatus() {
  return (
    <ul className="flex w-full max-w-sm flex-col divide-y rounded-xl border bg-card">
      {services.map((service) => (
        <li
          key={service.name}
          className="flex items-center justify-between px-4 py-3 text-sm"
        >
          <span className="font-medium">{service.name}</span>
          <Badge variant={service.variant}>
            <BadgeDot live={service.live} />
            {service.status}
          </Badge>
        </li>
      ))}
    </ul>
  )
}

With icons

Mark icons with data-icon="inline-start" or data-icon="inline-end"; the badge tightens its padding on that side and sizes the icon to 12px, or 14px at lg.

VerifiedAI generatedPrivate+12.4%Changelog
import {
  ArrowUpRightIcon,
  CircleCheckIcon,
  LockIcon,
  SparklesIcon,
  TrendingUpIcon,
} from "lucide-react"

import { Badge } from "@/components/ui/badge"

export default function BadgeWithIcon() {
  return (
    <div className="flex max-w-md flex-wrap items-center justify-center gap-2">
      <Badge variant="success">
        <CircleCheckIcon data-icon="inline-start" />
        Verified
      </Badge>
      <Badge variant="brand">
        <SparklesIcon data-icon="inline-start" />
        AI generated
      </Badge>
      <Badge variant="secondary">
        <LockIcon data-icon="inline-start" />
        Private
      </Badge>
      <Badge variant="outline">
        <TrendingUpIcon data-icon="inline-start" />
        +12.4%
      </Badge>
      <Badge variant="info">
        Changelog
        <ArrowUpRightIcon data-icon="inline-end" />
      </Badge>
    </div>
  )
}

A badge can navigate — to a changelog entry, a filtered list, a tag. Use the render prop so it becomes a real link; link badges pick up a hover state automatically.

import { ArrowRightIcon } from "lucide-react"
import Link from "next/link"

import { Badge } from "@/components/ui/badge"

const tags = [
  { label: "color", href: "/docs/foundations/color" },
  { label: "typography", href: "/docs/foundations/typography" },
  { label: "accessibility", href: "/docs/foundations/accessibility" },
  { label: "design-tokens", href: "/docs/foundations/tokens" },
]

export default function BadgeAsLink() {
  return (
    <div className="flex flex-col items-center gap-4">
      <Badge
        variant="brand"
        size="lg"
        render={<Link href="/docs/resources/changelog" />}
      >
        See what&apos;s new
        <ArrowRightIcon data-icon="inline-end" />
      </Badge>
      <div className="flex flex-wrap justify-center gap-1.5">
        {tags.map((tag) => (
          <Badge
            key={tag.label}
            variant="secondary"
            render={<Link href={tag.href} />}
          >
            #{tag.label}
          </Badge>
        ))}
      </div>
    </div>
  )
}
<Badge variant="brand" render={<Link href="/docs/resources/changelog" />}>
  See what's new
</Badge>

Counts

Counts label a destination with how much is waiting there. Cap them at 99+, use the solid default variant only for unread items that need action, and give the number context for screen readers.

import { BellIcon, InboxIcon, MessageSquareIcon } from "lucide-react"

import { Badge } from "@/components/ui/badge"

const items = [
  { label: "Inbox", icon: InboxIcon, count: 12, unread: true },
  { label: "Mentions", icon: MessageSquareIcon, count: 3, unread: true },
  { label: "Notifications", icon: BellIcon, count: 128, unread: false },
]

export default function BadgeCount() {
  return (
    <nav
      aria-label="Mailboxes"
      className="flex w-56 flex-col gap-px rounded-xl border bg-card p-1 shadow-raised"
    >
      {items.map(({ label, icon: Icon, count, unread }) => (
        <a
          key={label}
          href="#"
          className="flex h-9 items-center gap-2.5 rounded-lg px-2.5 text-sm hover:bg-accent"
        >
          <Icon className="size-4 text-muted-foreground" aria-hidden="true" />
          <span className="flex-1">{label}</span>
          <Badge
            variant={unread ? "default" : "secondary"}
            size="sm"
            className="min-w-5 tabular-nums"
          >
            {count > 99 ? "99+" : count}
            <span className="sr-only">
              {unread ? " unread messages" : " messages in total"}
            </span>
          </Badge>
        </a>
      ))}
    </nav>
  )
}

Guidelines

When to use

  • To show the state of an object: a payment, a deployment, a service.
  • To add short metadata: a role, a version, a plan, a tag.
  • To show a count of items waiting behind a navigation item.

When not to use

  • To trigger an action — use a Button.
  • To filter a view — use a Toggle Group or checkboxes, so the selected state is announced.
  • To explain something — if it needs more than two words, it needs a sentence, an Alert or a Tooltip.

Never color alone

PaidOverdue
DoA dot for scanning and a word for meaning. Readable in grayscale, for color-blind people and by screen readers.
Don’tA colored dot or chip with no label forces people to memorize a color code.

Keep it short

Expiring soon
DoOne or two words, sentence case, no punctuation.
Your card expires in 3 days, update it
Don’tSentences in badges get cut off and can't be scanned.

Restraint

Badges work because they are rare. If every row in a table carries three badges, none of them is noticed — keep one status badge per object and move the rest of the metadata into plain text.

Accessibility

  • Static by default. A badge renders a <span> and is not focusable. When it navigates, render it as a link so it gets a role, focus and a visible focus ring.
  • Status is text. Every status variant pairs a color with a word. BadgeDot is aria-hidden; the label carries the meaning.
  • Counts need context. "12" alone is ambiguous when read aloud. Add a visually hidden suffix — <span className="sr-only"> unread</span> — or put the count in the link's accessible name.
  • Contrast. Soft variants use step 11 text on step 3 fills, which the palette guarantees at 4.5:1 or more in both modes. Solid variants meet 4.5:1 as well.
  • Motion. The live pulse is decorative and is disabled under prefers-reduced-motion.

API reference

Badge

Renders a <span>. Built on Base UI's useRender, so it accepts a render prop.

PropTypeDefault
variant

Meaning and emphasis of the badge.

"default" | "secondary" | "outline" | "brand" | "brand-secondary" | "success" | "warning" | "destructive" | "info" | "ghost" | "link""default"
size

18, 20 or 24 pixels tall.

"sm" | "default" | "lg""default"
render

Renders a different element, such as a link, while keeping the badge styles.

ReactElement | (props, state) => ReactElementNo default

BadgeDot

A 6px status dot that takes the badge's text color.

PropTypeDefault
live

Pulses the dot for activity in progress. Disabled under reduced motion.

booleanfalse

badgeVariants

The class generator is exported so other elements — a table cell, a custom link — can share badge styles.

import { badgeVariants } from "@/components/ui/badge"

<span className={badgeVariants({ variant: "info", size: "sm" })}>Beta</span>