Empty State
StableMost 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-stateFirst 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.
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.
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
| Prop | Type | Default |
|---|---|---|
| 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 | undefined | false |
| 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
- 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
- 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
- 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 sourceHide 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>
);
}