Skip to content
  1. Records
  2. Badge

Badge

A short colored label for a status, a role or a plan.

PaidOpenOverdueDraft
import { Badge } from '@/components/badge'

export default function Example() {
  return (
    <div className="flex flex-wrap gap-2">
      <Badge color="emerald">Paid</Badge>
      <Badge color="sky">Open</Badge>
      <Badge color="red">Overdue</Badge>
      <Badge>Draft</Badge>
    </div>
  )
}
<script setup lang="ts">
import Badge from '@/components/badge.vue'
</script>

<template>
  <div class="flex flex-wrap gap-2">
    <Badge color="emerald">Paid</Badge>
    <Badge color="sky">Open</Badge>
    <Badge color="red">Overdue</Badge>
    <Badge>Draft</Badge>
  </div>
</template>

Installation

Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.

import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import React, { forwardRef } from 'react'
import { TouchTarget } from './button'
import { Link } from './link'

type BadgeColors = {
  text: string
  background: string
  backgroundHover: string
  darkText: string
  darkBackground: string
  darkBackgroundHover: string
}

const withOpacity = (color: string, percent: number) => `color-mix(in oklab, ${color} ${percent}%, transparent)`

type Opacities = [background: number, hover: number, darkBackground: number, darkHover: number]

const strong: Opacities = [15, 25, 10, 20]
const soft: Opacities = [20, 30, 10, 15]
const sameInDark: Opacities = [15, 25, 15, 20]

function tinted(
  color: string,
  text: string,
  darkText: string,
  [background, hover, dark, darkHover]: Opacities
): BadgeColors {
  return {
    text,
    background: withOpacity(color, background),
    backgroundHover: withOpacity(color, hover),
    darkText,
    darkBackground: withOpacity(color, dark),
    darkBackgroundHover: withOpacity(color, darkHover),
  }
}

const accentHover = withOpacity('var(--color-accent-500)', 25)

const colors = {
  red: tinted('var(--color-red-500)', 'var(--color-red-700)', 'var(--color-red-400)', strong),
  orange: tinted('var(--color-orange-500)', 'var(--color-orange-700)', 'var(--color-orange-400)', strong),
  amber: tinted('var(--color-amber-400)', 'var(--color-amber-700)', 'var(--color-amber-400)', soft),
  yellow: tinted('var(--color-yellow-400)', 'var(--color-yellow-700)', 'var(--color-yellow-300)', soft),
  lime: tinted('var(--color-lime-400)', 'var(--color-lime-700)', 'var(--color-lime-300)', soft),
  green: tinted('var(--color-green-500)', 'var(--color-green-700)', 'var(--color-green-400)', strong),
  emerald: tinted('var(--color-emerald-500)', 'var(--color-emerald-700)', 'var(--color-emerald-400)', strong),
  teal: tinted('var(--color-teal-500)', 'var(--color-teal-700)', 'var(--color-teal-300)', strong),
  cyan: tinted('var(--color-cyan-400)', 'var(--color-cyan-700)', 'var(--color-cyan-300)', soft),
  sky: tinted('var(--color-sky-500)', 'var(--color-sky-700)', 'var(--color-sky-300)', strong),
  blue: {
    ...tinted('var(--color-blue-500)', 'var(--color-blue-700)', 'var(--color-blue-400)', sameInDark),
    backgroundHover: accentHover,
    darkBackgroundHover: accentHover,
  },
  indigo: tinted('var(--color-indigo-500)', 'var(--color-indigo-700)', 'var(--color-indigo-400)', sameInDark),
  violet: tinted('var(--color-violet-500)', 'var(--color-violet-700)', 'var(--color-violet-400)', sameInDark),
  purple: tinted('var(--color-purple-500)', 'var(--color-purple-700)', 'var(--color-purple-400)', sameInDark),
  fuchsia: tinted('var(--color-fuchsia-400)', 'var(--color-fuchsia-700)', 'var(--color-fuchsia-400)', strong),
  pink: tinted('var(--color-pink-400)', 'var(--color-pink-700)', 'var(--color-pink-400)', strong),
  rose: tinted('var(--color-rose-400)', 'var(--color-rose-700)', 'var(--color-rose-400)', strong),
  zinc: {
    text: 'var(--color-zinc-700)',
    background: withOpacity('var(--color-zinc-600)', 10),
    backgroundHover: withOpacity('var(--color-zinc-600)', 20),
    darkText: 'var(--color-zinc-400)',
    darkBackground: withOpacity('var(--color-white)', 5),
    darkBackgroundHover: withOpacity('var(--color-white)', 10),
  },
} satisfies Record<string, BadgeColors>

function cssVariables(colors: BadgeColors) {
  return {
    '--badge-text': colors.text,
    '--badge-background': colors.background,
    '--badge-background-hover': colors.backgroundHover,
    '--badge-dark-text': colors.darkText,
    '--badge-dark-background': colors.darkBackground,
    '--badge-dark-background-hover': colors.darkBackgroundHover,
  } as React.CSSProperties
}

const base = [
  'inline-flex items-center gap-x-1.5 px-1.5 py-0.5 rounded-md',
  'font-medium text-sm/5 sm:text-xs/5',
  'text-(--badge-text) bg-(--badge-background) group-data-hover:bg-(--badge-background-hover)',
  'dark:text-(--badge-dark-text) dark:bg-(--badge-dark-background) dark:group-data-hover:bg-(--badge-dark-background-hover)',
  'forced-colors:outline',
]

const focusRing = 'focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-accent-500'

const buttonBase = ['group relative inline-flex rounded-md', focusRing]

type BadgeProps = { color?: keyof typeof colors }

export function Badge({
  color = 'zinc',
  dot = false,
  className,
  style,
  children,
  ...props
}: BadgeProps & { dot?: boolean } & React.ComponentPropsWithoutRef<'span'>) {
  return (
    <span {...props} style={{ ...cssVariables(colors[color]), ...style }} className={clsx(className, base)}>
      {dot && <span aria-hidden="true" className="size-1.5 shrink-0 rounded-full bg-current" />}
      {children}
    </span>
  )
}

export const BadgeButton = forwardRef(function BadgeButton(
  {
    color = 'zinc',
    className,
    children,
    ...props
  }: BadgeProps & { className?: string; children: React.ReactNode } & (
      | ({ href?: never } & Omit<Headless.ButtonProps, 'as' | 'className'>)
      | ({ href: string } & Omit<React.ComponentPropsWithoutRef<typeof Link>, 'className'>)
    ),
  ref: React.ForwardedRef<HTMLElement>
) {
  let classes = clsx(className, buttonBase)
  let badge = (
    <TouchTarget>
      <Badge color={color}>{children}</Badge>
    </TouchTarget>
  )

  return typeof props.href === 'string' ? (
    <Link {...props} className={classes} ref={ref as React.ForwardedRef<HTMLAnchorElement>}>
      {badge}
    </Link>
  ) : (
    <Headless.Button {...props} className={classes} ref={ref}>
      {badge}
    </Headless.Button>
  )
})

Also copy Button.

<script lang="ts">
type BadgeColors = {
  text: string
  background: string
  backgroundHover: string
  darkText: string
  darkBackground: string
  darkBackgroundHover: string
}

const withOpacity = (color: string, percent: number) => `color-mix(in oklab, ${color} ${percent}%, transparent)`

type Opacities = [background: number, hover: number, darkBackground: number, darkHover: number]

const strong: Opacities = [15, 25, 10, 20]
const soft: Opacities = [20, 30, 10, 15]
const sameInDark: Opacities = [15, 25, 15, 20]

function tinted(
  color: string,
  text: string,
  darkText: string,
  [background, hover, dark, darkHover]: Opacities
): BadgeColors {
  return {
    text,
    background: withOpacity(color, background),
    backgroundHover: withOpacity(color, hover),
    darkText,
    darkBackground: withOpacity(color, dark),
    darkBackgroundHover: withOpacity(color, darkHover),
  }
}

const accentHover = withOpacity('var(--color-accent-500)', 25)

const colors = {
  red: tinted('var(--color-red-500)', 'var(--color-red-700)', 'var(--color-red-400)', strong),
  orange: tinted('var(--color-orange-500)', 'var(--color-orange-700)', 'var(--color-orange-400)', strong),
  amber: tinted('var(--color-amber-400)', 'var(--color-amber-700)', 'var(--color-amber-400)', soft),
  yellow: tinted('var(--color-yellow-400)', 'var(--color-yellow-700)', 'var(--color-yellow-300)', soft),
  lime: tinted('var(--color-lime-400)', 'var(--color-lime-700)', 'var(--color-lime-300)', soft),
  green: tinted('var(--color-green-500)', 'var(--color-green-700)', 'var(--color-green-400)', strong),
  emerald: tinted('var(--color-emerald-500)', 'var(--color-emerald-700)', 'var(--color-emerald-400)', strong),
  teal: tinted('var(--color-teal-500)', 'var(--color-teal-700)', 'var(--color-teal-300)', strong),
  cyan: tinted('var(--color-cyan-400)', 'var(--color-cyan-700)', 'var(--color-cyan-300)', soft),
  sky: tinted('var(--color-sky-500)', 'var(--color-sky-700)', 'var(--color-sky-300)', strong),
  blue: {
    ...tinted('var(--color-blue-500)', 'var(--color-blue-700)', 'var(--color-blue-400)', sameInDark),
    backgroundHover: accentHover,
    darkBackgroundHover: accentHover,
  },
  indigo: tinted('var(--color-indigo-500)', 'var(--color-indigo-700)', 'var(--color-indigo-400)', sameInDark),
  violet: tinted('var(--color-violet-500)', 'var(--color-violet-700)', 'var(--color-violet-400)', sameInDark),
  purple: tinted('var(--color-purple-500)', 'var(--color-purple-700)', 'var(--color-purple-400)', sameInDark),
  fuchsia: tinted('var(--color-fuchsia-400)', 'var(--color-fuchsia-700)', 'var(--color-fuchsia-400)', strong),
  pink: tinted('var(--color-pink-400)', 'var(--color-pink-700)', 'var(--color-pink-400)', strong),
  rose: tinted('var(--color-rose-400)', 'var(--color-rose-700)', 'var(--color-rose-400)', strong),
  zinc: {
    text: 'var(--color-zinc-700)',
    background: withOpacity('var(--color-zinc-600)', 10),
    backgroundHover: withOpacity('var(--color-zinc-600)', 20),
    darkText: 'var(--color-zinc-400)',
    darkBackground: withOpacity('var(--color-white)', 5),
    darkBackgroundHover: withOpacity('var(--color-white)', 10),
  },
} satisfies Record<string, BadgeColors>

function cssVariables(colors: BadgeColors) {
  return {
    '--badge-text': colors.text,
    '--badge-background': colors.background,
    '--badge-background-hover': colors.backgroundHover,
    '--badge-dark-text': colors.darkText,
    '--badge-dark-background': colors.darkBackground,
    '--badge-dark-background-hover': colors.darkBackgroundHover,
  }
}

const base = [
  'inline-flex items-center gap-x-1.5 px-1.5 py-0.5 rounded-md',
  'font-medium text-sm/5 sm:text-xs/5',
  'text-(--badge-text) bg-(--badge-background) group-data-hover:bg-(--badge-background-hover)',
  'dark:text-(--badge-dark-text) dark:bg-(--badge-dark-background) dark:group-data-hover:bg-(--badge-dark-background-hover)',
  'forced-colors:outline',
]

export type BadgeColor = keyof typeof colors
</script>

<script setup lang="ts">
withDefaults(defineProps<{ color?: BadgeColor; dot?: boolean }>(), { color: 'zinc', dot: false })
</script>

<template>
  <span :style="cssVariables(colors[color])" :class="base">
    <span v-if="dot" aria-hidden="true" class="size-1.5 shrink-0 rounded-full bg-current" />
    <slot />
  </span>
</template>

Component API

PropDefaultDescription
Badgerenders a <span> element
colorzincText and background tone, from the eighteen colors listed below.
dotfalseAdds a small disc before the label, in the label color.
BadgeButtonrenders a <button> element
href-Turns the badge into a link instead of a button.
colorzincText and background tone, from the eighteen colors listed below.

Usage

In a badge, the color is the message. Give each status one color and keep it on every screen: if Overdue is red in the invoice list, it is red on the customer page too. Keep colors for states that ask for attention and leave descriptive labels neutral, or the colored ones stop standing out.

Invoice statuses

One color per status: green for settled, blue for waiting, red for late, and the default zinc for a draft nobody has sent yet:

PaidOpenOverdueDraft
import { Badge } from '@/components/badge'

export default function Example() {
  return (
    <div className="flex flex-wrap gap-2">
      <Badge color="emerald">Paid</Badge>
      <Badge color="sky">Open</Badge>
      <Badge color="red">Overdue</Badge>
      <Badge>Draft</Badge>
    </div>
  )
}
<script setup lang="ts">
import Badge from '@/components/badge.vue'
</script>

<template>
  <div class="flex flex-wrap gap-2">
    <Badge color="emerald">Paid</Badge>
    <Badge color="sky">Open</Badge>
    <Badge color="red">Overdue</Badge>
    <Badge>Draft</Badge>
  </div>
</template>

Neutral labels

A role or a plan describes a record without asking for action. Leave color out and the badge stays zinc, quiet next to the name it qualifies:

Aïcha Diallo Owner

Kofi Mensah Developer

<p className="flex items-center gap-2 text-sm/6 font-medium">
  Aïcha Diallo <Badge>Owner</Badge>
</p>
<p className="flex items-center gap-2 text-sm/6 font-medium">
  Kofi Mensah <Badge>Developer</Badge>
</p>
<p class="flex items-center gap-2 text-sm/6 font-medium">
  Aïcha Diallo <Badge>Owner</Badge>
</p>
<p class="flex items-center gap-2 text-sm/6 font-medium">
  Kofi Mensah <Badge>Developer</Badge>
</p>

Variants

Current state of an endpoint

dot adds a small disc in the label color. It suits states that change over time, like a webhook endpoint going live or being switched off, more than fixed categories:

LiveTest modeDisabled
<Badge color="emerald" dot>Live</Badge>
<Badge color="amber" dot>Test mode</Badge>
<Badge dot>Disabled</Badge>

Interaction

BadgeButton keeps the look of a badge and adds a focus ring, a stronger background on hover and, on touch screens, a hit area of at least 44 pixels around the label.

Quick status filters

Without href, BadgeButton renders a Headless UI button. Handle onClick to filter a list by the status on the badge:

Without href, BadgeButton renders a native button. Listen to @click to filter a list by the status on the badge:

Showing all 15 invoices.

'use client'

import { BadgeButton } from '@/components/badge'

export default function Example({ onFilter }) {
  return (
    <div className="flex flex-wrap gap-2">
      <BadgeButton color="red" onClick={() => onFilter('overdue')}>
        Overdue · 3
      </BadgeButton>
      <BadgeButton color="sky" onClick={() => onFilter('open')}>
        Open · 12
      </BadgeButton>
    </div>
  )
}
<script setup lang="ts">
import BadgeButton from '@/components/badge-button.vue'

const emit = defineEmits<{ filter: [status: string] }>()
</script>

<template>
  <div class="flex flex-wrap gap-2">
    <BadgeButton color="red" @click="emit('filter', 'overdue')">Overdue · 3</BadgeButton>
    <BadgeButton color="sky" @click="emit('filter', 'open')">Open · 12</BadgeButton>
  </div>
</template>

With href, the badge becomes a link through the kit Link component, here to every customer on the same plan:

import { BadgeButton } from '@/components/badge'

export default function Example() {
  return (
    <BadgeButton color="indigo" href="/customers?plan=scale">
      Scale plan
    </BadgeButton>
  )
}
<script setup lang="ts">
import BadgeButton from '@/components/badge-button.vue'
</script>

<template>
  <BadgeButton color="indigo" href="/customers?plan=scale">Scale plan</BadgeButton>
</template>

Available colors

Each color defines its own text and background tones for light and dark mode, so a label keeps the same weight on both themes. On a BadgeButton, blue takes the accent color on hover.

redorangeamberyellowlimegreenemeraldtealcyanskyblueindigovioletpurplefuchsiapinkrosezinc