Badge
Six semantic tones across three shapes, kept as separate axes — so a red outline badge is something you can ask for rather than something the variant list forgot.
A dot and a word, with no icon-only mode by design — one in twelve men cannot separate the red state from the green one, so the word is structural rather than optional.
components/ui/status-badge.tsxnpx hoverlab add status-badge
Or over MCP, from your editor's agent — no account needed.
Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.
Interactive, in your current theme. Every state below is the real component — type in it, tab through it, switch the theme.
One file. Create components/ui/status-badge.tsx and paste.
/**
* <StatusBadge> — a dot, a word, and the two things a colour cannot carry.
*
* Separate from `Badge` because it answers a different question. A badge
* labels a thing ("Beta", "Pro", "3"); a status badge reports the state of
* a thing right now, and that brings two requirements a badge does not
* have:
*
* 1. It must not rely on colour alone. Roughly 1 in 12 men cannot
* separate the red state from the green one, so the dot is paired
* with a word, always — this component has no icon-only mode, by
* design, and that is the accessibility rule made structural.
* 2. A live state has to be announced when it changes. `aria-live`
* belongs on the thing that persists across the change, so it is on
* the wrapper here and the text inside it is what gets replaced.
*
* The `pulse` ring is for states that are genuinely in motion — deploying,
* syncing, recording — and it is animation-guarded, because a permanent
* pulse behind text is exactly what `prefers-reduced-motion` exists for.
*/
import * as React from 'react'
export type Status =
| 'online'
| 'offline'
| 'busy'
| 'away'
| 'pending'
| 'success'
| 'failed'
| 'draft'
interface StatusStyle {
dot: string
text: string
bg: string
label: string
/** Whether this state is one that is actively happening. */
live?: boolean
}
const STATUSES: Record<Status, StatusStyle> = {
online: { dot: 'bg-emerald-500', text: 'text-emerald-700 dark:text-emerald-400', bg: 'bg-emerald-500/10', label: 'Online' },
offline: { dot: 'bg-slate-400', text: 'text-muted-foreground', bg: 'bg-muted', label: 'Offline' },
busy: { dot: 'bg-rose-500', text: 'text-rose-700 dark:text-rose-400', bg: 'bg-rose-500/10', label: 'Busy' },
away: { dot: 'bg-amber-500', text: 'text-amber-700 dark:text-amber-400', bg: 'bg-amber-500/10', label: 'Away' },
pending: { dot: 'bg-sky-500', text: 'text-sky-700 dark:text-sky-400', bg: 'bg-sky-500/10', label: 'In progress', live: true },
success: { dot: 'bg-emerald-500', text: 'text-emerald-700 dark:text-emerald-400', bg: 'bg-emerald-500/10', label: 'Succeeded' },
failed: { dot: 'bg-destructive', text: 'text-destructive', bg: 'bg-destructive/10', label: 'Failed' },
draft: { dot: 'bg-slate-400', text: 'text-muted-foreground', bg: 'bg-muted', label: 'Draft' },
}
export interface StatusBadgeProps {
status: Status
/** Overrides the default word. The word itself is never optional. */
label?: string
/** Dot on a plain background instead of a filled pill. */
subtle?: boolean
/** Announce changes to assistive tech. For states that update in place. */
live?: boolean
className?: string
}
export function StatusBadge({
status,
label,
subtle = false,
live,
className = '',
}: StatusBadgeProps) {
const style = STATUSES[status]
const text = label ?? style.label
const announce = live ?? style.live === true
return (
<span
// On the wrapper, which survives the state change — a live region
// that is itself replaced announces nothing.
aria-live={announce ? 'polite' : undefined}
className={[
'inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full',
'text-xs font-medium leading-none',
subtle ? 'px-0' : `px-2 py-1 ${style.bg}`,
style.text,
className,
]
.filter(Boolean)
.join(' ')}
>
<span className="relative flex h-2 w-2 shrink-0">
{style.live ? (
<span
aria-hidden
className={`absolute inline-flex h-full w-full animate-ping rounded-full opacity-60 motion-reduce:animate-none ${style.dot}`}
/>
) : null}
<span aria-hidden className={`relative inline-flex h-2 w-2 rounded-full border border-transparent ${style.dot}`} />
</span>
{text}
</span>
)
}
Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.
| Prop | Type | Default |
|---|---|---|
statusrequired | Status | — |
labelOverrides the default word. The word itself is never optional. | string | — |
subtleDot on a plain background instead of a filled pill. | boolean | false |
liveAnnounce changes to assistive tech. For states that update in place. | boolean | — |
className | string | '' |
The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.
Six semantic tones across three shapes, kept as separate axes — so a red outline badge is something you can ask for rather than something the variant list forgot.
Removable tags and the field that makes them, including the part nobody does: after removing a chip, focus moves to the next one instead of falling to the top of the document.
Shapes that match what replaces them, so the page does not jump when data lands — and a group wrapper that announces "loading" once instead of describing six grey rectangles.