Skip to contentVibraUI
Data display

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

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

Examples

A domain of its own

A fulfilment vocabulary layered over the built-in map, in a table of shipments.

Props

PropTypeDefaultDescription
statusstring—The raw status from your data — case, spaces, and dashes are all fine.
variant"success" | "warning" | "danger" | "info" | "neutral" | "primary"resolved from statusSkips the lookup and paints the pill outright.
dotbooleanfalseDraws a 1.5-size dot in the tone before the label. Off by default: the word carries the state and the tint the tone.
pulsebooleanfalseAdds 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.
mapRecord<string, StatusVariant>—Extra or replacement status words, consulted before the built-in map.
labelReact.ReactNodestatus, underscores as spacesVisible text; the default capitalises the first letter, so "in_progress" reads "In progress".
DEFAULT_STATUS_MAPRecord<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

Source

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