Skip to content
All components

Empty State

Stable

Primitive · System

Most products render one empty state. Never-recorded, filtered-to-empty, source-unavailable, restricted, and pending mean entirely different things, and conflating them has caused documented harm. Unavailable is the dangerous one: a section that failed to load and renders as “no results” tells a clinician the patient has no allergies when the allergy service was simply down. title is required rather than defaulted, because the correct sentence for an empty allergy list is not the correct sentence for an empty problem list.

pnpm dlx shadcn@latest add @oxygenui/empty-state

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/empty-state.json directly.

Preview

Every state, switchable.

Live · synthetic data

No allergy information recorded

This is not the same as no known allergies. Ask and record before prescribing.

Allergies could not be loaded

The source system did not respond. Do not read this section as complete.

Last read 08:41

No results match these filters

12 results are hidden by the active filters.

Ordered, not yet resulted

Expected within 4 hours.

“Unavailable” rendered as “no results” tells a clinician the patient has no allergies when the service was down.

Never recordedFiltered to emptySource unavailableRestrictedPendingCompact

Usage

Props are the FHIR resource.

import { EmptyState } from "@/components/oxygen/empty-state";

<EmptyState
  reason="never"
  title="No allergy information recorded"
  description="This is not the same as no known allergies. Ask and record before prescribing."
/>

<EmptyState reason="unavailable" title="Allergies could not be loaded" lastCheckedLabel="Last read 08:41" />

Dependencies

  • lucide-react
  • clsx
  • tailwind-merge
Empty State props
PropTypeDefault
title

Required. There is no safe default: the correct sentence for an empty allergy list is not the correct sentence for an empty problem list, and getting it wrong asserts a clinical negative.

string
action

React.ReactNode
compact

Single-line rendering for table cells and clinical density.

boolean | undefinedfalse
description

string | undefined
lastCheckedLabel

When the source was last successfully read. An empty list without this is indistinguishable from a current one, and staleness is clinical.

string | undefined
reason

EmptyReason | undefined"never"

Guidance

When to use it, and when not to.

DECISION SURFACE / 6 RULES

Recommended context

Use it

03
  • Every list, table, and section that can render nothing.
  • With reason=\u201cunavailable\u201d whenever a fetch failed, never \u201cnever\u201d.
  • With lastCheckedLabel wherever staleness would change a clinical reading.

Guardrails

Don't

03
  • Asserting a clinical negative. \u201cNo allergies recorded\u201d is safe; \u201cNo allergies\u201d is a claim.
  • Reusing one empty state for a failed fetch and a genuinely empty list.
  • Illustration-led empty states on clinical surfaces \u2014 the sentence is the content.

Quality

What was tested, and what is still missing.

Filtered emptiness announced
Emptiness that follows a user action uses a polite live region; emptiness that was always there does not, because it is not news.
Icon decorative
The glyph is hidden from assistive technology. The message carries the meaning.
Reason exposed
data-empty-reason is on the element for testing and for styling without re-deriving state.

Open gaps

Known limitations

03
  • Copy is yours \u2014 the component enforces the distinction, not the wording.
  • No retry behaviour built in; pass an action.
  • Does not detect its own reason; the caller knows why the section is empty.

Source

Exactly what lands in your repository.

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

Show source136 lines
"use client";

/**
 * EmptyState — distinguishes the five reasons a clinical section shows nothing.
 *
 * Most products render one empty state. These five mean entirely different
 * things, and conflating them has caused documented harm:
 *
 *   never      — no data has ever been recorded here
 *   filtered   — data exists; the current filters exclude all of it
 *   unavailable— the source system did not answer. This is NOT empty.
 *   restricted — data exists and this viewer may not see it
 *   pending    — ordered or expected, not yet resulted
 *
 * `unavailable` is the dangerous one. A section that failed to load and renders
 * as "no results" tells a clinician the patient has no allergies when the
 * allergy service was simply down.
 *
 * The copy rule that follows from this: never assert a clinical negative the
 * data does not support. "No allergies recorded" is safe. "No allergies" is a
 * claim, and this component will not make it for you — which is why `title` is
 * a required prop rather than a helpful default.
 */

import * as React from "react";
import { CircleSlash, Clock, Filter, Lock, TriangleAlert } from "lucide-react";
import { cn } from "@/lib/utils";

export type EmptyReason = "never" | "filtered" | "unavailable" | "restricted" | "pending";

const REASON_ICON: Record<EmptyReason, React.ComponentType<{ className?: string }>> = {
  never: CircleSlash,
  filtered: Filter,
  unavailable: TriangleAlert,
  restricted: Lock,
  pending: Clock,
};

/**
 * Class names written out in full. Tailwind resolves classes by scanning
 * source text, so a class assembled from `reason` produces no CSS and an
 * unavailable section renders as an ordinary empty one.
 */
const REASON_CLASS: Record<EmptyReason, string> = {
  never: "border-[var(--ox-border)] text-[var(--ox-text-subtle)]",
  filtered: "border-[var(--ox-border)] text-[var(--ox-text-subtle)]",
  unavailable:
    "border-[var(--ox-status-high-border)] bg-[var(--ox-status-high-bg)] text-[var(--ox-status-high)]",
  restricted: "border-[#ddd6fe] bg-[var(--ox-flag-restricted-bg)] text-[var(--ox-flag-restricted)]",
  pending: "border-[var(--ox-border)] text-[var(--ox-text-muted)]",
};

export interface EmptyStateProps extends React.HTMLAttributes<HTMLDivElement> {
  reason?: EmptyReason;
  /**
   * Required. There is no safe default: the correct sentence for an empty
   * allergy list is not the correct sentence for an empty problem list, and
   * getting it wrong asserts a clinical negative.
   */
  title: string;
  description?: string;
  /**
   * When the source was last successfully read. An empty list without this is
   * indistinguishable from a current one, and staleness is clinical.
   */
  lastCheckedLabel?: string;
  action?: React.ReactNode;
  /** Single-line rendering for table cells and clinical density. */
  compact?: boolean;
}

export function EmptyState({
  reason = "never",
  title,
  description,
  lastCheckedLabel,
  action,
  compact = false,
  className,
  ...props
}: EmptyStateProps) {
  const Icon = REASON_ICON[reason];

  // Emptiness that follows a user action (filtering) is announced; emptiness
  // that was always there is not, because it is not news.
  const live = reason === "filtered" ? "polite" : undefined;

  if (compact) {
    return (
      <div
        role="status"
        aria-live={live}
        data-empty-reason={reason}
        className={cn(
          "inline-flex items-center gap-1.5 text-[length:var(--ox-text-sm)]",
          REASON_CLASS[reason].replace(/border-\S+/g, "").trim(),
          className,
        )}
        {...props}
      >
        <Icon aria-hidden="true" className="size-3.5 shrink-0" />
        <span>{title}</span>
      </div>
    );
  }

  return (
    <div
      role="status"
      aria-live={live}
      data-empty-reason={reason}
      className={cn(
        "flex flex-col items-center gap-2 rounded-[var(--ox-radius)] border border-dashed px-[var(--ox-density-pad-x)] py-8 text-center",
        REASON_CLASS[reason],
        className,
      )}
      {...props}
    >
      {/* Decorative: the message carries the meaning, not the glyph. */}
      <Icon aria-hidden="true" className="size-5" />
      <p className="text-[length:var(--ox-text-sm)] font-semibold">{title}</p>
      {description && (
        <p className="max-w-[46ch] text-[length:var(--ox-text-xs)] leading-relaxed text-[var(--ox-text-muted)]">
          {description}
        </p>
      )}
      {lastCheckedLabel && (
        <p className="font-[family-name:var(--ox-font-numeric)] text-[length:var(--ox-text-2xs)] text-[var(--ox-text-subtle)]">
          {lastCheckedLabel}
        </p>
      )}
      {action && <div className="mt-1">{action}</div>}
    </div>
  );
}