← ui

badge

ui

a small label for a status, a version or a count, plain or glass

npx shadcn add @ag/badge
Already installed? Preview an update
npx shadcn add @ag/badge --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-12
  • New: variant="info", the tokens' info tone (the current one, a suggestion waiting), beside success, warning and destructive.
  • Fixed: a status badge's words on a light surface are its colour deepened (lightness held at 0.55, as TONE_WORDS), so "current" in the info tone reads on its pale wash; on a dark surface, the colour itself.
  • Changed: count must be a whole number from 0; a negative or NaN count throws instead of drawing "-2" or "NaN".
Updated 2026-10-12 · release notes

also called tag, pill

shelf controls

uses material

used by components, deployment

the demo above, verbatim

badge.demo.tsx
"use client"

import { CircleCheck } from "lucide-react"
import { Sample } from "@/app/samples"
import { Badge } from "@/registry/base-nova/ui/badge"
import { MaterialProvider } from "@/registry/base-nova/ui/material"

// #region badges
function Badges() {
  return (
    <div className="flex flex-wrap items-center gap-2">
      <Badge>v1.4.0</Badge>
      <Badge variant="secondary">
        <CircleCheck /> live
      </Badge>
      <Badge variant="outline">draft</Badge>
      <Badge variant="success">passed</Badge>
      <Badge variant="warning">5 warnings</Badge>
      <Badge variant="destructive">failed</Badge>
      <Badge variant="info">current</Badge>
    </div>
  )
}
// #endregion

export default function BadgeDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="materials"
        title="every variant, plain and glass"
        with="badges"
      >
        <div className="grid gap-4">
          <Badges />
          <MaterialProvider material="glass">
            <div className="rounded-xl bg-[url(/lightbox/shore.jpg)] bg-cover bg-center p-6">
              <Badges />
            </div>
          </MaterialProvider>
        </div>
      </Sample>
      <Sample name="link" glance title="a badge that is a link">
        <Badge render={<a href="/updates" />} variant="secondary">
          what changed
        </Badge>
      </Sample>
    </div>
  )
}

the source, every file an install writes

badge.tsx
"use client"

import { useRender } from "@base-ui/react/use-render"
import { cn } from "@/lib/utils"
import {
  type Material,
  type Scheme,
  useMaterial,
  useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import {
  glassStyle,
  glassVariants,
  schemeOf,
} from "@/registry/base-nova/ui/material-glass"

// A small label: a status, a version, a count, a kind. Metadata's voice (mono, small,
// lowercase), which is where it parts from shadcn's sans badge: a badge says what
// something IS about the thing beside it, never what it says, and the registry keeps
// that voice in mono everywhere. shadcn's four variants; a pill one step below the
// smallest button. Not interactive unless `render` makes it a link.
// `material="glass"`: a sliver of the same glass the buttons are made of.

export type BadgeVariant =
  | "default"
  | "secondary"
  | "outline"
  | "success"
  | "warning"
  | "destructive"
  | "info"

/** A status's colour, as the tokens name it (the alert's kinds, the same words);
 *  `info` is news that is no trouble (the current one, a suggestion waiting). */
const STATUS: Partial<Record<BadgeVariant, string>> = {
  success: "var(--tone-ok)",
  warning: "var(--tone-warn)",
  destructive: "var(--tone-bad)",
  info: "var(--tone-info)",
}

const BASE =
  "inline-flex shrink-0 items-center whitespace-nowrap rounded-full font-mono lowercase"
/** A label's shape: words at the badge's height. */
const LABEL = "h-6 gap-1.5 px-2.5 text-[11px] [&_svg]:size-3 [&_svg]:shrink-0"
/** A count's shape: a circle while it is one digit (its width at least its
 *  height, the digit centred), a pill as digits come, small enough to sit beside a
 *  label's words (a tab's, a menu item's) on their centre line. */
const COUNT = "h-4 min-w-4 justify-center px-1 text-[10px] tabular-nums"

/** A status in plain: its colour washed under its words. On a light surface the
 *  words are the colour deepened (its lightness held at 0.55, as `TONE_WORDS`), so
 *  they read on their pale wash; on a dark one, the colour itself. */
const PLAIN_STATUS =
  "bg-(--ag-badge-ink)/15 text-[oklch(from_var(--ag-badge-ink)_min(l,0.55)_c_h)] scheme-dark:text-(--ag-badge-ink)"
const PLAIN: Record<BadgeVariant, string> = {
  default: "bg-foreground text-background",
  secondary: "bg-foreground/8 text-foreground/80",
  outline: "text-foreground/75 ring-1 ring-foreground/15 ring-inset",
  success: PLAIN_STATUS,
  warning: PLAIN_STATUS,
  destructive: PLAIN_STATUS,
  info: PLAIN_STATUS,
}

/** A status in glass: the glass stained its colour as any tint is, the colour said
 *  again on its mark (an icon) and its words the glass's own. Its words in the colour
 *  too, on a stain twice a tint's, read far louder than the tints beside it. */
const GLASS_STATUS = "text-current/85 [&_svg]:text-(--ag-badge-ink)"
const GLASS: Record<BadgeVariant, string> = {
  default: "text-current/85",
  secondary:
    "text-current/70 [--glass-rim:0.7] [--glass-tint:rgb(238_241_245/0.4)] scheme-dark:[--glass-tint:rgb(16_16_18/0.35)]",
  // The glass's edge with no body under it (`edge: "only"`).
  outline: "text-current/80",
  success: GLASS_STATUS,
  warning: GLASS_STATUS,
  destructive: GLASS_STATUS,
  info: GLASS_STATUS,
}

export type BadgeProps = useRender.ComponentProps<"span"> & {
  variant?: BadgeVariant
  /** A count instead of words: drawn as a circle while one digit, a pill past
   *  it, sized to sit beside a label (`<TabsTab>alerts <Badge count={3} /></TabsTab>`).
   *  It is the badge's content: give it no children. */
  count?: number
  material?: Material
  /** The colour scheme it stands in (material): default the nearest provider's. */
  scheme?: Scheme
}

export function Badge({
  variant = "default",
  count,
  material,
  scheme,
  render,
  className,
  style,
  children,
  ref,
  ...props
}: BadgeProps) {
  if (count !== undefined && children !== undefined)
    throw new Error("Badge: a `count` is its content; give it no children")
  if (count !== undefined && !(Number.isInteger(count) && count >= 0))
    throw new Error(`Badge: a count is a whole number from 0, got ${count}`)
  const m = useMaterial(material)
  const [s, here] = useSurfaceScheme<HTMLElement>(scheme, ref)
  const ink = STATUS[variant]
  return useRender({
    defaultTagName: "span",
    render,
    ref: here,
    props: {
      "data-slot": "badge",
      "data-variant": variant,
      "data-material": m,
      style: ink
        ? ({
            "--ag-badge-ink": ink,
            ...(m === "glass" ? glassStyle({ tint: ink }) : {}),
            ...style,
          } as React.CSSProperties)
        : style,
      className: cn(
        BASE,
        count === undefined ? LABEL : COUNT,
        schemeOf(s),
        m === "glass"
          ? [
              glassVariants({
                shape: "surface",
                edge: variant === "outline" ? "only" : true,
              }),
              // A pill: its radius half its height.
              count === undefined
                ? "[--glass-blur:8px] [--glass-radius:12px]"
                : "[--glass-blur:8px] [--glass-radius:8px]",
              GLASS[variant],
            ]
          : PLAIN[variant],
        className,
      ),
      children: count === undefined ? children : count,
      ...props,
    },
  })
}