Status indicator
A tone-colored dot with an optional label, for presence and service health.
Server-compatible: no hooks, no client boundary. Presence words and health words share the same four tones, so a dot means the same beside a person as beside a service. The status name is always rendered as visually hidden text ("Online", "Critical"), so it never reads by color alone, and the root becomes a status region as soon as it carries a label. The pulse ring is dropped for readers who prefer reduced motion.
Install
$
npx shadcn@latest add @vibra/status-indicatorNeeds the @vibra registry in your components.json — set it up once.
Examples
import { StatusIndicator, type StatusIndicatorStatus } from "@/components/ui/status-indicator"
const SERVICES: { name: string; status: StatusIndicatorStatus; detail: string; pulse?: boolean }[] = [
{ name: "api.vibra.example", status: "online", detail: "142 ms", pulse: true },
{ name: "ingest-worker", status: "busy", detail: "queue 4.2k" },
{ name: "warehouse-eu-1", status: "away", detail: "degraded" },
{ name: "reports-cron", status: "offline", detail: "no heartbeat" },
]
export default function StatusIndicatorDemo() {
return (
<div className="w-full max-w-md overflow-hidden rounded-lg border border-border">
{SERVICES.map((service) => (
<div
key={service.name}
className="flex items-center justify-between gap-4 border-b border-border px-3 py-2.5 last:border-b-0"
>
<StatusIndicator
status={service.status}
pulse={service.pulse}
label={<span className="font-mono text-label">{service.name}</span>}
/>
<span className="text-xs tabular-nums text-muted-foreground">{service.detail}</span>
</div>
))}
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| status | "online" | "offline" | "busy" | "away" | "success" | "warning" | "danger" | "info" | "neutral" | — | Sets the dot's tone and the hidden status word. |
| label | React.ReactNode | — | Visible text beside the dot — a host name, a person, a queue. |
| pulse | boolean | false | Adds a slow ring around the dot; reserve it for a state that is actively changing. |
| size | "sm" | "default" | "default" | sm shrinks the dot and drops the label to text-xs. |
Dependencies
Registry
Source
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
export type StatusIndicatorStatus =
| "online"
| "offline"
| "busy"
| "away"
| "success"
| "warning"
| "danger"
| "info"
| "neutral"
// Presence words and health words share the same four tones, so a dot means the
// same thing whether it sits beside a person or beside a service.
const STATUS_COLORS: Record<StatusIndicatorStatus, string> = {
online: "bg-success",
success: "bg-success",
busy: "bg-danger",
danger: "bg-danger",
away: "bg-warning",
warning: "bg-warning",
info: "bg-info",
offline: "bg-muted-foreground",
neutral: "bg-muted-foreground",
}
// The same tones as ink, for the shapes that are drawn rather than filled.
const STATUS_INK: Record<StatusIndicatorStatus, string> = {
online: "text-success",
success: "text-success",
busy: "text-danger",
danger: "text-danger",
away: "text-warning",
warning: "text-warning",
info: "text-info",
offline: "text-muted-foreground",
neutral: "text-muted-foreground",
}
// Read out before the label, so the status never depends on the dot's color.
const STATUS_LABELS: Record<StatusIndicatorStatus, string> = {
online: "Online",
offline: "Offline",
busy: "Busy",
away: "Away",
success: "Healthy",
warning: "Warning",
danger: "Critical",
info: "Info",
neutral: "Unknown",
}
const statusIndicatorVariants = cva("inline-flex w-fit items-center", {
variants: {
size: {
default: "gap-2 text-sm",
sm: "gap-1.5 text-xs",
},
},
defaultVariants: { size: "default" },
})
const statusIndicatorDotVariants = cva("relative flex shrink-0", {
variants: {
size: { default: "size-2", sm: "size-1.5" },
},
defaultVariants: { size: "default" },
})
/**
* The six shapes a state can take, so a reader who cannot separate the tones
* still separates the states: an empty ring is not started, a dashed ring is
* waiting, a half-filled ring is running, a tick is done, a cross failed. The
* dot is the presence marker it has always been.
*/
export type StatusIndicatorShape = "dot" | "ring" | "dashed" | "progress" | "check" | "x"
// One 16-unit box for all of them, drawn at 14px (12px at sm), so they line up
// on the same optical centre as the text beside them.
const SHAPE_PATHS: Record<Exclude<StatusIndicatorShape, "dot">, React.ReactNode> = {
ring: <circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeWidth="1.5" />,
dashed: (
<circle
cx="8"
cy="8"
r="6"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeDasharray="2.6 2.6"
strokeLinecap="round"
/>
),
progress: (
<>
<circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeWidth="1.5" />
<path d="M8 3.5A4.5 4.5 0 0 1 8 12.5Z" fill="currentColor" />
</>
),
check: (
<>
<circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeWidth="1.5" />
<path
d="m5.4 8.2 1.9 1.9 3.3-3.9"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</>
),
x: (
<>
<circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeWidth="1.5" />
<path
d="m6 6 4 4M10 6l-4 4"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</>
),
}
/**
* The workflow words a board already uses, each with the tone and the shape
* that carry it. Hand one to a StatusIndicator, or read it in a table cell.
*/
export const WORKFLOW_STATUS: Record<
string,
{ status: StatusIndicatorStatus; shape: StatusIndicatorShape; label: string }
> = {
todo: { status: "neutral", shape: "ring", label: "To do" },
queued: { status: "neutral", shape: "dashed", label: "Queued" },
scheduled: { status: "info", shape: "dashed", label: "Scheduled" },
in_progress: { status: "info", shape: "progress", label: "In progress" },
running: { status: "info", shape: "progress", label: "Running" },
blocked: { status: "warning", shape: "x", label: "Blocked" },
failed: { status: "danger", shape: "x", label: "Failed" },
done: { status: "success", shape: "check", label: "Done" },
succeeded: { status: "success", shape: "check", label: "Succeeded" },
}
export type StatusIndicatorProps = React.ComponentProps<"span"> & {
status: StatusIndicatorStatus
/** Visible text beside the dot — a host name, a person, a queue. */
label?: React.ReactNode
/** Adds a slow ring around the dot. Reserve it for a state that is actively changing. */
pulse?: boolean
/** What the marker is drawn as. Anything but `dot` is an outline in the tone's ink. */
shape?: StatusIndicatorShape
size?: NonNullable<VariantProps<typeof statusIndicatorVariants>["size"]>
}
function StatusIndicator({
className,
status,
label,
pulse = false,
shape = "dot",
size = "default",
...props
}: StatusIndicatorProps) {
return (
<span
data-slot="status-indicator"
data-status={status}
data-shape={shape}
data-size={size}
data-pulse={pulse || undefined}
role={label ? "status" : undefined}
className={cn(statusIndicatorVariants({ size }), className)}
{...props}
>
{shape === "dot" ? (
<span className={cn(statusIndicatorDotVariants({ size }))} aria-hidden="true">
{pulse ? (
<span
data-slot="status-indicator-pulse"
className={cn(
"absolute inline-flex size-full animate-ping rounded-full opacity-60 motion-reduce:hidden in-data-[motion=reduced]:hidden",
STATUS_COLORS[status]
)}
/>
) : null}
<span
data-slot="status-indicator-dot"
className={cn("relative inline-flex size-full rounded-full", STATUS_COLORS[status])}
/>
</span>
) : (
<svg
data-slot="status-indicator-shape"
viewBox="0 0 16 16"
aria-hidden="true"
className={cn("shrink-0", size === "sm" ? "size-3" : "size-3.5", STATUS_INK[status])}
>
{SHAPE_PATHS[shape]}
</svg>
)}
<span className="sr-only">{STATUS_LABELS[status]}</span>
{label ? (
<span data-slot="status-indicator-label" className="truncate">
{label}
</span>
) : null}
</span>
)
}
export { StatusIndicator, STATUS_LABELS, statusIndicatorVariants }