Skip to content

Badge

Compact labelling with seven tones, three appearances and an optional second, colour-independent signal.

Feedbackstarterlabeltagchipcountcategory

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

The exact file rendered in the preview above.

import { GitBranch, Sparkles } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { DemoRow, DemoStage } from './_kit'

export default function BadgeDemo() {
  return (
    <DemoStage>
      <DemoRow label="Tones">
        <Badge>Neutral</Badge>
        <Badge tone="accent">Accent</Badge>
        <Badge tone="success">Passing</Badge>
        <Badge tone="warning">Deprecated</Badge>
        <Badge tone="danger">Breaking</Badge>
        <Badge tone="info">Preview</Badge>
      </DemoRow>

      <DemoRow label="Appearance">
        <Badge appearance="soft" tone="accent">
          Soft
        </Badge>
        <Badge appearance="outline" tone="accent">
          Outline
        </Badge>
        <Badge appearance="solid" tone="accent">
          Solid
        </Badge>
        <Badge appearance="solid" tone="neutral">
          Solid neutral
        </Badge>
      </DemoRow>

      <DemoRow
        label="With a second signal"
        description="A dot or icon keeps the meaning off hue alone."
      >
        <Badge tone="success" dot>
          Operational
        </Badge>
        <Badge tone="danger" dot>
          Failing
        </Badge>
        <Badge tone="accent" icon={<Sparkles className="size-3" />}>
          New
        </Badge>
        <Badge icon={<GitBranch className="size-3" />}>main</Badge>
      </DemoRow>

      <DemoRow label="Sizes">
        <Badge size="sm">v1.0.0</Badge>
        <Badge size="md">v1.0.0</Badge>
      </DemoRow>
    </DemoStage>
  )
}

components/demos/badge.tsx

import { GitBranch, Sparkles } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { DemoRow, DemoStage } from './_kit'

export default function BadgeDemo() {
  return (
    <DemoStage>
      <DemoRow label="Tones">
        <Badge>Neutral</Badge>
        <Badge tone="accent">Accent</Badge>
        <Badge tone="success">Passing</Badge>
        <Badge tone="warning">Deprecated</Badge>
        <Badge tone="danger">Breaking</Badge>
        <Badge tone="info">Preview</Badge>
      </DemoRow>

      <DemoRow label="Appearance">
        <Badge appearance="soft" tone="accent">
          Soft
        </Badge>
        <Badge appearance="outline" tone="accent">
          Outline
        </Badge>
        <Badge appearance="solid" tone="accent">
          Solid
        </Badge>
        <Badge appearance="solid" tone="neutral">
          Solid neutral
        </Badge>
      </DemoRow>

      <DemoRow
        label="With a second signal"
        description="A dot or icon keeps the meaning off hue alone."
      >
        <Badge tone="success" dot>
          Operational
        </Badge>
        <Badge tone="danger" dot>
          Failing
        </Badge>
        <Badge tone="accent" icon={<Sparkles className="size-3" />}>
          New
        </Badge>
        <Badge icon={<GitBranch className="size-3" />}>main</Badge>
      </DemoRow>

      <DemoRow label="Sizes">
        <Badge size="sm">v1.0.0</Badge>
        <Badge size="md">v1.0.0</Badge>
      </DemoRow>
    </DemoStage>
  )
}

components/ui/badge.tsx

import type { HTMLAttributes, ReactNode } from 'react'
import { variants } from '@/lib/variants'

/**
 * Badge
 *
 * A compact, non-interactive label. Tones map to the status token trios, and
 * because a badge is often the only signal in a dense table, the `dot` option
 * exists to add a second, colour-independent cue alongside the text.
 */
const badgeVariants = variants('inline-flex items-center gap-1.5 whitespace-nowrap font-medium', {
  variants: {
    tone: {
      neutral: 'bg-surface-sunken text-ink-muted border-line',
      accent: 'bg-accent-soft text-accent-soft-ink border-accent-line',
      success: 'bg-success-soft text-success border-success-line',
      warning: 'bg-warning-soft text-warning border-warning-line',
      danger: 'bg-danger-soft text-danger border-danger-line',
      info: 'bg-info-soft text-info border-info-line',
      inverse: 'bg-surface-inverse text-ink-inverse border-transparent',
    },
    appearance: {
      soft: 'border',
      outline: 'border bg-transparent',
      solid: 'border border-transparent',
    },
    size: {
      sm: 'h-4.5 rounded-sm px-1.5 text-2xs',
      md: 'h-5.5 rounded-sm px-2 text-xs',
    },
  },
  defaultVariants: { tone: 'neutral', appearance: 'soft', size: 'md' },
  compound: [
    { appearance: 'solid', tone: 'accent', class: 'bg-accent text-accent-ink' },
    { appearance: 'solid', tone: 'success', class: 'bg-success text-white' },
    { appearance: 'solid', tone: 'warning', class: 'bg-warning text-white' },
    { appearance: 'solid', tone: 'danger', class: 'bg-danger text-white' },
    { appearance: 'solid', tone: 'info', class: 'bg-info text-white' },
    { appearance: 'solid', tone: 'neutral', class: 'bg-ink text-ink-inverse' },
    { appearance: 'outline', tone: 'neutral', class: 'text-ink-muted' },
  ],
})

export type BadgeTone = 'neutral' | 'accent' | 'success' | 'warning' | 'danger' | 'info' | 'inverse'

export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
  tone?: BadgeTone
  appearance?: 'soft' | 'outline' | 'solid'
  size?: 'sm' | 'md'
  /** Adds a leading dot so the badge does not rely on hue alone. */
  dot?: boolean
  icon?: ReactNode
}

export function Badge({
  tone = 'neutral',
  appearance = 'soft',
  size = 'md',
  dot = false,
  icon,
  className,
  children,
  ...props
}: BadgeProps) {
  return (
    <span className={badgeVariants({ tone, appearance, size, className })} {...props}>
      {dot ? (
        <span className="size-1.5 shrink-0 rounded-full bg-current" aria-hidden="true" />
      ) : null}
      {icon}
      {children}
    </span>
  )
}

lib/variants.ts

import { cn, type ClassValue } from './cn'

/**
 * A tiny, fully typed variant resolver.
 *
 * Foundry does not depend on `cva`. The API below covers the two cases the
 * library actually needs — a map of named variants and a set of compound
 * overrides — in ~40 lines, and keeps variant keys inferred so a typo in
 * `variant="primry"` is a type error at the call site.
 */
type VariantMap = Record<string, Record<string, ClassValue>>

type VariantProps<M extends VariantMap> = {
  /** Boolean variants may be passed as real booleans; they resolve to the
      `'true'` / `'false'` keys of the variant map. */
  [K in keyof M]?: keyof M[K] | boolean
}

interface VariantConfig<M extends VariantMap> {
  variants: M
  defaultVariants?: VariantProps<M>
  compound?: Array<VariantProps<M> & { class: ClassValue }>
}

export function variants<M extends VariantMap>(base: ClassValue, config: VariantConfig<M>) {
  return function resolve(props: VariantProps<M> & { className?: ClassValue } = {}): string {
    const selected = { ...config.defaultVariants, ...props } as VariantProps<M>
    const classes: ClassValue[] = [base]

    for (const key of Object.keys(config.variants) as Array<keyof M>) {
      const value = selected[key]
      if (value == null) continue
      const group = config.variants[key]
      if (!group) continue
      classes.push(group[typeof value === 'boolean' ? String(value) : (value as string)])
    }

    for (const rule of config.compound ?? []) {
      const { class: className, ...conditions } = rule
      const matches = Object.entries(conditions).every(
        ([key, value]) => selected[key as keyof M] === value,
      )
      if (matches) classes.push(className)
    }

    classes.push(props.className)
    return cn(...classes)
  }
}

export type { VariantProps }

Demo source — adapt to your project. Foundry is not published as a package.

Usage

For categorising and counting. A badge describes what something *is*; Status describes what something is *doing*. Keeping them separate stops both from becoming a generic coloured pill.

  • Use the dot when a badge is the only signal in a dense table — hue alone is not enough.
  • Keep badge text to one or two words; it does not wrap gracefully by design.
  • Solid badges are loud. One per row, at most.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Soft
  • Outline
  • Solid
  • With dot
  • With icon
  • Two sizes

Accessibility

Not a control
Badges are non-interactive. If it can be clicked or removed, it is a button or a chip, and needs a label.
Meaning
The text carries the meaning; the colour is reinforcement.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.