Skip to content
Monogem

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-6 for "empty states, features"), colored --muted-foreground (set explicitly, since an icon sitting alone above text has no surrounding text color to inherit via currentColor). 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

empty-state-showcase.tsx
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)
CauseNothing has been created yetSomething exists, but the current query or filter matches nothing
HeadingNames the entity that doesn't exist yet — "No projects yet"Names the query or filter — "No results for “{query}”"
DescriptionWhat this list/report is for, if the heading alone isn't enoughWhat was searched or filtered, if not already in the heading
Primary actionCreates 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.
IconOptional, 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

StateLook
Zero-data, no actionIcon (optional) + heading + description, no button — reserved for a truly action-less region
Zero-data, with actionIcon (optional) + heading + description + primary Button
No-resultsIcon (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

  1. 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).
  2. 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.
  3. 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.
  4. 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:

GapTokenpxRationale
Icon → Heading--scale-416Spacing's own "default gap between elements" recipe
Heading → Description--scale-14Same tight lockup Card's Header uses for Title → Description (gap-y-1)
Text block → Action row--scale-624Spacing's "gap between form fields" recipe, reused here as the gap before a distinct interactive cluster
Between two actions--scale-312Same 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.

TokenWhere used
--foregroundHeading text (inherited default)
--muted-foregroundDescription text, optional icon
--icon-stroke-widthThe optional icon's stroke
--scale-6 (icon)Optional icon size — Icons' standalone recipe
--scale-4 / --scale-1 / --scale-6 / --scale-3Internal gaps — see Spacing
--scale-16Outer breathing room, standalone placement only
Button's own tokensAction 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 CommandEmpty already solves — the containing region needs aria-live="polite" so the change is announced, mirroring CommandEmpty'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-foreground on --background / --card already passes WCAG AA in both themes (measured on Input); --foreground for 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-sm bounds 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.

Related Components / Patterns

  • Button — the action in an empty state.
  • Card — a common container for an empty region.
  • Table — an empty result set.
  • Search — the no-results case.