Pattern
Empty States
Overview
An empty state is what a content region shows when it has nothing to render — a list with no
items yet, a search or filter with no matches, or a report with nothing to report. This is a
composition pattern, not a component: Monogem ships no <EmptyState> primitive. An empty state is built by centering a short heading, an
optional one-line description, an optional icon, and an optional action — from
Typography, Button, and
Icons — inside whatever region already frames that part of the page
(a bare section, a Card's content, a Table's TableEmpty cell, a
Command list's CommandEmpty).
Anatomy
<div> centered column, text-center, max-w-sm mx-auto
├─ optional Icon Lucide glyph, --scale-6 (24px), --muted-foreground, aria-hidden
├─ Heading Heading 4 recipe (text-xl font-semibold leading-snug)
├─ optional Description Body small (text-sm leading-normal), --muted-foreground
└─ optional Action row primary Button, optional secondary Button (variant="outline")
- Icon — a single Lucide glyph at the icon foundation's "standalone" size
(Icons already names
--scale-6for "empty states, features"), colored--muted-foreground(set explicitly, since an icon sitting alone above text has no surrounding text color to inherit viacurrentColor). Optional in every variant — see Icon and illustration guidance. - Heading — the required part. Names the specific thing that's empty (a list, a search, a report), never a generic placeholder. Uses the Heading 4 recipe; the real heading level is contextual, the same "appearance only" rule Card uses for its Title.
- Description — optional. One sentence of context, added only when the heading alone leaves the user unsure what to do next.
- Action row — optional. At most one primary Button, plus at most one secondary
Button (
variant="outline") when a second, non-competing action exists. See Content hierarchy & action priority.
No dedicated container primitive ships for the outer <div> — the surrounding region is always
whatever already frames that part of the page. A grid of cards with nothing to show renders the
empty state inside that grid's own wrapper; a table with no rows renders it inside
TableEmpty; a filtered list inside a <Card> renders it inside that Card's
CardContent. Empty States never invents its own bordered box to hold itself in.
Live Example
Zero-data, no action — a truly action-less region
No activity yet
This audit log fills in as events happen. There's nothing to review yet.
Zero-data, with a primary action — creates the first item
No projects yet
Projects group your work so your team can find it later.
No-results — distinguished from zero-data by copy and a “clear,” not “create,” action
No results for “design system”
Try a different search term, or clear your filters.
Composed inside a Card — no bespoke bordered wrapper of its own
Inbox zero
New messages will show up here.
Code Example
import { FolderOpen, Inbox, SearchX, type LucideIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
/**
* Live Empty States example — Empty States is a composition PATTERN, not a
* component. There is no `<EmptyState>` import and no
* `components/ui/empty-state.tsx`: every block below is plain markup wiring
* Typography, Button, and an optional Lucide icon together by hand, per
* docs/patterns/empty-states.md.
*
* `EmptyState` here is a local, unexported layout helper — not a shipped
* primitive — the same role FieldError/FieldHelp play in form-showcase.tsx.
*/
function EmptyState({
icon: Icon,
heading,
description,
action,
secondaryAction,
}: {
icon?: LucideIcon;
heading: string;
description?: string;
action?: { label: string };
secondaryAction?: { label: string };
}) {
return (
<div className="mx-auto flex max-w-sm flex-col items-center text-center">
{Icon ? (
<Icon
aria-hidden="true"
className="mb-4 size-6 text-muted-foreground [stroke-width:var(--icon-stroke-width)]"
/>
) : null}
<div className="flex flex-col gap-1">
<h4 className="text-xl font-semibold leading-snug">{heading}</h4>
{description ? (
<p className="text-sm leading-normal text-muted-foreground">
{description}
</p>
) : null}
</div>
{action ? (
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
<Button>{action.label}</Button>
{secondaryAction ? (
<Button variant="outline">{secondaryAction.label}</Button>
) : null}
</div>
) : null}
</div>
);
}
export function EmptyStateShowcase() {
return (
<div className="flex flex-col gap-10">
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Zero-data, no action — a truly action-less region
</h3>
<EmptyState
icon={Inbox}
heading="No activity yet"
description="This audit log fills in as events happen. There's nothing to review yet."
/>
</section>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Zero-data, with a primary action — creates the first item
</h3>
<EmptyState
icon={FolderOpen}
heading="No projects yet"
description="Projects group your work so your team can find it later."
action={{ label: "New project" }}
secondaryAction={{ label: "Learn more" }}
/>
</section>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
No-results — distinguished from zero-data by copy and a “clear,” not
“create,” action
</h3>
<EmptyState
icon={SearchX}
heading="No results for “design system”"
description="Try a different search term, or clear your filters."
action={{ label: "Clear filters" }}
/>
</section>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Composed inside a Card — no bespoke bordered wrapper of its own
</h3>
<Card className="max-w-md">
<CardContent className="py-6">
<EmptyState
icon={Inbox}
heading="Inbox zero"
description="New messages will show up here."
/>
</CardContent>
</Card>
</section>
</div>
);
}Variants
Empty States has no visual variants — its axis is cause, and the cause changes the copy and which action (if any) makes sense. Two causes must never look or read the same:
| Zero-data (first use) | No-results (search / filter) | |
|---|---|---|
| Cause | Nothing has been created yet | Something exists, but the current query or filter matches nothing |
| Heading | Names the entity that doesn't exist yet — "No projects yet" | Names the query or filter — "No results for “{query}”" |
| Description | What this list/report is for, if the heading alone isn't enough | What was searched or filtered, if not already in the heading |
| Primary action | Creates the first item — "New project" | Clears the query or filter — "Clear filters" / "Clear search." Never a "create" action here — there may be plenty of real data, just not under this filter. |
| Icon | Optional, representing the entity (e.g. an inbox/folder glyph) | Optional, typically a search glyph if used at all |
A third, action-less case exists for read-only or system-driven regions with no valid action to offer (a report with no activity yet, an audit log that's empty) — heading and optional description only, no action row. It still follows the same specific-heading rule as the other two.
Icon and illustration guidance
An icon is optional in every variant, never required. Monogem does not ship or require decorative illustration for empty states — a stock illustration standing in for a designed state is the exact Meaningful Content failure the Dashboard Empty State example calls out. When an icon is used:
- One glyph only, from Lucide, at the standalone size
(Icons —
--scale-6, 24px). - Colored
--muted-foreground,aria-hidden="true"— it is decorative, and the heading/ description carry all of the meaning. - Never a compound illustration, multi-color graphic, or product-specific artwork. If a real screen needs a richer visual treatment, that's a product-level illustration system, out of scope for this pattern.
States
| State | Look |
|---|---|
| Zero-data, no action | Icon (optional) + heading + description, no button — reserved for a truly action-less region |
| Zero-data, with action | Icon (optional) + heading + description + primary Button |
| No-results | Icon (optional) + heading naming the query + description + primary Button that clears the query/filter |
Loading is explicitly not a state of this pattern — a region that's still fetching renders Skeleton placeholders, never an empty state; Skeleton's own guidance already warns against the reverse mistake ("leave skeletons on screen as a permanent empty state"). Only render an Empty State once loading has finished and the result set is confirmed empty.
Usage Guidance
Content hierarchy & action priority
- One heading, always specific. Name the actual entity or context — "No projects yet," "No results for “{query}”," "No invoices this quarter" — never a generic "No data" or "Nothing here." This is the Specificity heuristic's own worked example (a Dashboard Empty State).
- One description, only if needed. Skip it when the heading is already unambiguous; add one sentence when the user needs a nudge toward what happens next or why the state occurred.
- At most one primary action. The action that actually resolves the state — creates the first item, clears the filter, retries the request. If there's no real action available, render none rather than a disabled-looking or dead button — a Purposeful Styling failure the same worked example names directly.
- At most one secondary action. Added only when it doesn't compete with the primary for attention — e.g. a "Learn more" link beside a "Create project" primary. Two actions is the bound; a third means the state is trying to do too much.
Spacing
Every gap is an existing Spacing step:
| Gap | Token | px | Rationale |
|---|---|---|---|
| Icon → Heading | --scale-4 | 16 | Spacing's own "default gap between elements" recipe |
| Heading → Description | --scale-1 | 4 | Same tight lockup Card's Header uses for Title → Description (gap-y-1) |
| Text block → Action row | --scale-6 | 24 | Spacing's "gap between form fields" recipe, reused here as the gap before a distinct interactive cluster |
| Between two actions | --scale-3 | 12 | Same action-row gap Form's action row uses between Buttons |
Standalone placement (an empty state as an entire page section, not nested in a smaller
container) adds outer breathing room using Spacing's "space between page sections" step
(--scale-16, 64px) above and below; nested placements (inside a Card, a table cell, a list
wrapper) take no extra outer padding of their own — the parent region's existing padding is
enough.
Tokens
Empty States composes text roles, --muted-foreground, Button, and the icon foundation's existing “standalone” size recipe; it introduces no component-specific styling values.
| Token | Where used |
|---|---|
--foreground | Heading text (inherited default) |
--muted-foreground | Description text, optional icon |
--icon-stroke-width | The optional icon's stroke |
--scale-6 (icon) | Optional icon size — Icons' standalone recipe |
--scale-4 / --scale-1 / --scale-6 / --scale-3 | Internal gaps — see Spacing |
--scale-16 | Outer breathing room, standalone placement only |
| Button's own tokens | Action row — reused through Button, untouched |
Do / Don’t
Do
- Name the specific thing that's empty in the heading — the list, the search, the report.
- Distinguish a no-results state from a zero-data state in both copy and the primary action (clear vs. create) — never reuse zero-data copy for a filtered-to-nothing result.
- Render zero or one action, not a row of options; drop the action entirely when nothing real resolves the state.
- Compose the region's own container (Card, Table, a bare section) — never wrap the empty state in a new bordered box of its own.
- Keep an icon optional and single-glyph when used at all.
Don’t
- Ship a generic "No data" / "Nothing here yet" heading with no specifics.
- Show a disabled-looking or non-functional button as filler when no action is actually available.
- Require a decorative illustration — Monogem provides no illustration system, and one is never mandatory for this pattern.
- Build an
<EmptyState>primitive that wraps Typography/Button/Card — the same rejection Form makes for a<Form>wrapper: it would hide the contracts this pattern is meant to expose. - Leave a Skeleton on screen as a stand-in for this pattern once loading has actually finished.
Accessibility
- Heading level is contextual. The Heading 4 recipe is appearance only; pick whatever level is correct in the page's real outline — the same rule Card states for its own Title.
- Icon is decorative.
aria-hidden="true"on the glyph — the heading and description text carry all of the meaning a screen reader needs; nothing is icon-only. - Announcing a state that appears without navigation. When an empty state can appear in
place — a list emptied by a live filter, the same situation
Command's
CommandEmptyalready solves — the containing region needsaria-live="polite"so the change is announced, mirroringCommandEmpty's own contract. A state that only appears on a fresh page load or route change needs no live region; the page load itself is the announcement. - Action Button is unchanged. The action row's focus ring, keyboard operation, and disabled handling all come from Button as documented — nothing about being inside an empty state changes it.
- Contrast.
--muted-foregroundon--background/--cardalready passes WCAG AA in both themes (measured on Input);--foregroundfor the heading is near-maximal contrast.
Responsive Behavior
Empty States is already single-column and centered at every width, so there is no layout to collapse. Two things do change at narrow widths:
- The action row wraps (
flex-wrap) instead of forcing two buttons onto one line — a Button at its default size already meets the 40px tap target, so wrapping never shrinks a target to reach it. max-w-smbounds the heading and description so a line of text never stretches edge-to-edge on a wide viewport, matching the width every other composed-text block in this system (Card, Form) already uses.