Badge
A short colored label for a status, a role or a plan.
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
| Prop | Default | Description |
|---|---|---|
Badgerenders a <span> element | ||
color | zinc | Text and background tone, from the eighteen colors listed below. |
dot | false | Adds 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. |
color | zinc | Text 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:
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:
<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>Jump to related records
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.