Status badge
A status word as a tinted, hairlined pill, resolved from the word itself and never read by colour alone.
Server-compatible: no hooks, no client boundary. resolveStatusVariant normalises before it looks up — lower case, and spaces and dashes folded into underscores — so "In Progress", "in-progress", and "IN_PROGRESS" are one key; anything it does not know resolves to neutral. A caller's map is consulted first and the defaults second, so it extends rather than replaces DEFAULT_STATUS_MAP; its own keys go through the same normalisation, and lookups use Object.hasOwn, so a status called "toString" resolves to neutral rather than to a function. The state is carried by the label text, which the dot only decorates — pass label to override the derived wording, or variant to skip the lookup entirely.
Install
npx shadcn@latest add @vibra/status-badgeNeeds the @vibra registry in your components.json — set it up once.
Examples
import { StatusBadge } from "@/components/ui/status-badge"
const statuses = [
"active",
"in_progress",
"queued",
"running",
"failed",
"draft",
"paid",
"overdue",
]
export default function StatusBadgeDemo() {
return (
<div className="flex w-full max-w-lg flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
{statuses.map((status) => (
<StatusBadge key={status} status={status} />
))}
</div>
<div className="flex flex-wrap items-center gap-2">
<StatusBadge status="running" pulse />
<StatusBadge status="completed" dot />
<StatusBadge status="pending" size="sm" />
<StatusBadge status="cancelled" size="sm" />
</div>
</div>
)
}A domain of its own
A fulfilment vocabulary layered over the built-in map, in a table of shipments.
import { SimpleTable, type SimpleTableColumn } from "@/components/ui/simple-table"
import { StatusBadge, type StatusVariant } from "@/components/ui/status-badge"
// Words this warehouse uses that no default map could know. Anything missing
// here — "paid", "cancelled" — still resolves through the built-in map.
const FULFILMENT_STATUS: Record<string, StatusVariant> = {
"in transit": "info",
delivered: "success",
"awaiting pickup": "warning",
returned: "danger",
"label printed": "primary",
}
type Shipment = {
order: string
customer: string
status: string
}
const shipments: Shipment[] = [
{ order: "SO-40218", customer: "Olivia Martin", status: "Delivered" },
{ order: "SO-40217", customer: "Jackson Lee", status: "In transit" },
{ order: "SO-40216", customer: "Isabella Nguyen", status: "Awaiting pickup" },
{ order: "SO-40215", customer: "William Kim", status: "Label printed" },
{ order: "SO-40214", customer: "Sofia Davis", status: "Returned" },
{ order: "SO-40213", customer: "Ethan Brooks", status: "Cancelled" },
]
const columns: SimpleTableColumn<Shipment>[] = [
{ key: "order", header: "Order", cell: (row) => <span className="font-mono">{row.order}</span> },
{ key: "customer", header: "Customer" },
{
key: "status",
header: "Status",
cell: (row) => <StatusBadge status={row.status} map={FULFILMENT_STATUS} />,
},
]
export default function StatusBadgeMap() {
return (
<SimpleTable
className="w-full"
columns={columns}
rows={shipments}
rowKey="order"
caption="Fulfilment, last 24 hours"
size="sm"
/>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| status | string | — | The raw status from your data — case, spaces, and dashes are all fine. |
| variant | "success" | "warning" | "danger" | "info" | "neutral" | "primary" | resolved from status | Skips the lookup and paints the pill outright. |
| dot | boolean | false | Draws a 1.5-size dot in the tone before the label. Off by default: the word carries the state and the tint the tone. |
| pulse | boolean | false | Adds a slow ring around the dot, and the dot with it; reserve it for a state that is actively changing. |
| size | "sm" | "default" | "default" | sm tightens the padding and drops the text to 11px. |
| map | Record<string, StatusVariant> | — | Extra or replacement status words, consulted before the built-in map. |
| label | React.ReactNode | status, underscores as spaces | Visible text; the default capitalises the first letter, so "in_progress" reads "In progress". |
| DEFAULT_STATUS_MAP | Record<string, StatusVariant> | — | The 27 status words the badge knows: active, pending, failed, draft, running, queued, and their kin. |
| resolveStatusVariant | (status: string, map?: Record<string, StatusVariant>) => StatusVariant | — | Normalises and resolves a status; "In Progress" becomes warning, an unknown word neutral. |
| normalizeStatus | (status: string) => string | — | Lower-cases a status and folds spaces and dashes into underscores. |
| statusLabel | (status: string) => string | — | The default label for a status key: "in_progress" becomes "In progress". |
Dependencies
Registry
Source
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
// A tinted capsule with a hairline in its own tone at a third of the ink:
// the tint says which tone, the line gives the pill an edge on a card, and the
// word inside says what the state is.
const statusBadgeVariants = cva(
"inline-flex w-fit items-center rounded-full border font-medium whitespace-nowrap [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
success: "border-success/30 bg-success-muted text-success",
warning: "border-warning/30 bg-warning-muted text-warning",
danger: "border-danger/30 bg-danger-muted text-danger",
info: "border-info/30 bg-info-muted text-info",
neutral: "border-border bg-muted text-muted-foreground",
// The accent as an opaque tint, like every other tone: an alpha fill
// over a warm card drifts the hue and reads a different colour on each
// plane.
primary: "border-brand/30 bg-brand-muted text-brand",
},
size: {
default: "h-5 gap-1.5 px-2 text-xs",
sm: "h-4 gap-1 px-1.5 text-2xs",
},
},
defaultVariants: { variant: "neutral", size: "default" },
}
)
// Derived from the pill's own variants, so a tone can never be named in the
// map without a class to paint it.
export type StatusVariant = NonNullable<VariantProps<typeof statusBadgeVariants>["variant"]>
/**
* The status words a dashboard already knows, mapped to the six pill variants.
* Extend or override it per component with the `map` prop.
*/
export const DEFAULT_STATUS_MAP: Record<string, StatusVariant> = {
active: "success",
completed: "success",
paid: "success",
resolved: "success",
online: "success",
success: "success",
pending: "warning",
paused: "warning",
in_progress: "warning",
processing: "warning",
warning: "warning",
failed: "danger",
error: "danger",
cancelled: "danger",
canceled: "danger",
overdue: "danger",
offline: "danger",
draft: "neutral",
archived: "neutral",
inactive: "neutral",
unknown: "neutral",
running: "info",
open: "info",
new: "info",
info: "info",
scheduled: "primary",
queued: "primary",
}
/** Lower-cases a status and folds spaces and dashes into underscores, so "In Progress" and "in-progress" are one key. */
export function normalizeStatus(status: string): string {
return status.trim().toLowerCase().replace(/[\s-]+/g, "_")
}
// Object.hasOwn, not `key in map`: a status called "toString" or "constructor"
// answers true against Object's prototype and would come back with a function
// where a variant belongs.
function lookup(map: Record<string, StatusVariant>, key: string): StatusVariant | undefined {
if (Object.hasOwn(map, key)) return map[key]
// A caller's map may be written the way the data reads — { "In Transit": … } —
// so its own keys go through the same normalisation as the status.
for (const own of Object.keys(map)) {
if (normalizeStatus(own) === key) return map[own]
}
return undefined
}
/** Resolves a status string to a pill variant: the caller's `map` first, then the defaults, then "neutral". */
export function resolveStatusVariant(
status: string,
map?: Record<string, StatusVariant>
): StatusVariant {
const key = normalizeStatus(status)
return (map && lookup(map, key)) ?? lookup(DEFAULT_STATUS_MAP, key) ?? "neutral"
}
/** Turns a status key into its default label: "in_progress" → "In progress". */
export function statusLabel(status: string): string {
const words = status.trim().replace(/[_-]+/g, " ").replace(/\s+/g, " ")
return words.charAt(0).toUpperCase() + words.slice(1)
}
export type StatusBadgeProps = React.ComponentProps<"span"> & {
/** The raw status from your data — case, spaces, and dashes are all fine. */
status: string
/** Skips the lookup and paints the pill outright. */
variant?: StatusVariant
/** A dot before the word, in the tone. Off by default: the word carries the state, and the tint the tone. */
dot?: boolean
/** Adds a slow ring around the dot (and the dot with it). Reserve it for a state that is actively changing. */
pulse?: boolean
size?: NonNullable<VariantProps<typeof statusBadgeVariants>["size"]>
/** Extra or replacement status words, consulted before the defaults. */
map?: Record<string, StatusVariant>
/** Visible text; defaults to the status with underscores as spaces. */
label?: React.ReactNode
/**
* Replaces the dot — usually a shaped <StatusIndicator>, so the state reads
* without the colour: a tick for done, a cross for failed, a dashed ring for
* queued.
*/
indicator?: React.ReactNode
}
/** A status word as a tinted, hairlined pill — the state is in the text, never in the colour alone. */
function StatusBadge({
className,
status,
variant,
dot = false,
pulse = false,
size = "default",
map,
label,
indicator,
children,
...props
}: StatusBadgeProps) {
const resolved = variant ?? resolveStatusVariant(status, map)
// The ring is drawn around the dot, so asking for the pulse asks for the dot.
const showDot = dot || pulse
return (
<span
data-slot="status-badge"
data-status={normalizeStatus(status)}
data-variant={resolved}
data-size={size}
data-pulse={pulse || undefined}
className={cn(statusBadgeVariants({ variant: resolved, size }), className)}
{...props}
>
{indicator ? (
<span data-slot="status-badge-indicator" className="flex shrink-0 items-center">
{indicator}
</span>
) : showDot ? (
<span
data-slot="status-badge-dot"
aria-hidden="true"
className={cn("relative flex shrink-0", size === "sm" ? "size-1" : "size-1.5")}
>
{pulse ? (
<span
data-slot="status-badge-pulse"
className="absolute inline-flex size-full animate-ping rounded-full bg-current opacity-60 motion-reduce:hidden in-data-[motion=reduced]:hidden"
/>
) : null}
<span className="relative inline-flex size-full rounded-full bg-current" />
</span>
) : null}
{children ?? label ?? statusLabel(status)}
</span>
)
}
export { StatusBadge, statusBadgeVariants }