Skip to contentVibraUI
Feedback & status

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-indicator

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
status"online" | "offline" | "busy" | "away" | "success" | "warning" | "danger" | "info" | "neutral"—Sets the dot's tone and the hidden status word.
labelReact.ReactNode—Visible text beside the dot — a host name, a person, a queue.
pulsebooleanfalseAdds 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

Source

components/ui/status-indicator.tsx
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 }