Skip to content
All components

Status Badge

Stable

Primitive

The status chip every other Oxygen component uses, so that severity is consistent across the whole system. Two rules are enforced by the API itself: children is required, because an icon alone is not a label but a rebus; and tone maps to a semantic token rather than a raw color, so a caller passes “critical”, never “red”, and the token decides what that means in light, dark, and forced-colors modes.

pnpm dlx shadcn@latest add @oxygenui/status-badge

First install? Add "@oxygenui": "https://oxygenui.design/r/{name}.json" to the registries block of your components.json first — or skip the config and pass https://oxygenui.design/r/status-badge.json directly.

Preview

Every state, switchable.

Live · synthetic data
Critical highHighLowNormalNot interpretedCompleted

Six tones, each mapping to a status token. A caller passes meaning, never a color.

CriticalHighLowNormalUnknownNeutral

Usage

Props are the FHIR resource.

import { StatusBadge } from "@/components/oxygen/status-badge";

<StatusBadge tone="critical">Critical high</StatusBadge>
<StatusBadge tone="unknown">Not interpreted</StatusBadge>

Dependencies

  • lucide-react
  • clsx
  • tailwind-merge
Status Badge props
PropTypeDefault
children

The label. Required — a badge without text is not accessible.

React.ReactNode
icon

Replace the tone's default icon. Pass `null` only when an adjacent icon already carries the meaning.

React.ComponentType<{ className?: string; }> | null | undefined
size

'sm' | 'md' | undefined"sm"
tone

StatusTone | undefined"neutral"

Guidance

When to use it, and when not to.

DECISION SURFACE / 5 RULES

Recommended context

Use it

02
  • Anywhere a state needs to be shown consistently with the rest of the system.
  • With a label that names the state, not its severity — “Critical high”, not “Danger”.

Guardrails

Don't

03
  • As a button or a filter control. It is not interactive.
  • With icon={null} unless a neighbouring icon already conveys the same meaning.
  • Inventing a tone for a non-clinical concept. Use neutral.

Quality

What was tested, and what is still missing.

Label required
The API has no icon-only variant, so a badge can never ship without an accessible label.
Token-driven
Tones resolve through status tokens, keeping contrast correct in light, dark, and forced-colors modes.

Open gaps

Known limitations

02
  • Not interactive — no button, link, or dismiss behavior.
  • Six tones only. Additional semantics belong in a token, not a one-off color.

Source

Exactly what lands in your repository.

Read directly from the published registry, so this can never drift from what the CLI installs.

Show source97 lines
"use client";

/**
 * StatusBadge — the shared severity and status chip.
 *
 * Every Oxygen component that communicates state uses this, so that "critical"
 * looks and reads identically on a lab result, a medication, and an allergy.
 *
 * Two rules are enforced by the API itself:
 *
 *   1. `children` is required. There is no icon-only variant, because an icon
 *      alone is not a label — it is a rebus.
 *   2. Tone maps to a semantic token, never to a raw color. A caller cannot
 *      pass "red"; they pass "critical", and the token decides what that
 *      looks like in light, dark, and forced-colors modes.
 */

import * as React from "react";
import {
  CircleAlert,
  CircleCheck,
  CircleHelp,
  CircleMinus,
  CirclePause,
  TrendingDown,
  TrendingUp,
} from "lucide-react";
import { cn } from "@/lib/utils";

export type StatusTone = "critical" | "high" | "low" | "normal" | "unknown" | "neutral";

/**
 * Class names written out in full. Tailwind resolves classes by scanning
 * source text, so a template literal built from `tone` produces no CSS and
 * the badge renders unstyled — which silently deletes the severity signal.
 */
const TONE_CLASS: Record<StatusTone, string> = {
  critical:
    "border-[var(--ox-status-critical-border)] bg-[var(--ox-status-critical-bg)] text-[var(--ox-status-critical)]",
  high: "border-[var(--ox-status-high-border)] bg-[var(--ox-status-high-bg)] text-[var(--ox-status-high)]",
  low: "border-[var(--ox-status-low-border)] bg-[var(--ox-status-low-bg)] text-[var(--ox-status-low)]",
  normal:
    "border-[var(--ox-status-normal-border)] bg-[var(--ox-status-normal-bg)] text-[var(--ox-status-normal)]",
  unknown:
    "border-[var(--ox-status-unknown-border)] bg-[var(--ox-status-unknown-bg)] text-[var(--ox-status-unknown)]",
  neutral: "border-[var(--ox-border)] bg-[var(--ox-bg-muted)] text-[var(--ox-text-muted)]",
};

const TONE_ICON: Record<StatusTone, React.ComponentType<{ className?: string }>> = {
  critical: CircleAlert,
  high: TrendingUp,
  low: TrendingDown,
  normal: CircleCheck,
  unknown: CircleHelp,
  neutral: CircleMinus,
};

export interface StatusBadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
  tone?: StatusTone;
  /** Replace the tone's default icon. Pass `null` only when an adjacent icon already carries the meaning. */
  icon?: React.ComponentType<{ className?: string }> | null;
  size?: "sm" | "md";
  /** The label. Required — a badge without text is not accessible. */
  children: React.ReactNode;
}

export function StatusBadge({
  tone = "neutral",
  icon,
  size = "sm",
  children,
  className,
  ...props
}: StatusBadgeProps) {
  const Icon = icon === null ? null : (icon ?? TONE_ICON[tone]);

  return (
    <span
      className={cn(
        "inline-flex items-center gap-1.5 whitespace-nowrap rounded-[var(--ox-radius-full)] border font-semibold",
        size === "sm"
          ? "px-2 py-0.5 text-[length:var(--ox-text-xs)]"
          : "px-2.5 py-1 text-[length:var(--ox-text-sm)]",
        TONE_CLASS[tone],
        className,
      )}
      {...props}
    >
      {Icon && <Icon aria-hidden="true" className={size === "sm" ? "size-3.5" : "size-4"} />}
      {children}
    </span>
  );
}

/** Paused / on-hold icon, exported so medication and care components share it. */
export { CirclePause as PauseIcon };