Skip to content

EmptyState

<EmptyState> fills a region that has no content and explains why. Four variants — they’re not aesthetic choices; each names a different reason for emptiness, and each has its own copy and CTA shape.

import from "@na/ui/components/EmptyState" ▶ Open in Storybook packages/ui/src/components/EmptyState.tsx

When to use

  • A list, table, or grid with zero rows.
  • A dashboard whose data hasn’t been generated yet.
  • A region the user can’t see (permission denied).
  • A region that failed to load (network error).

Don’t conflate the variants. A permission-blocked empty state with a “Create your first” CTA is cruel. Pick the variant that names the actual reason.

Blank slate

User is new — there’s nothing to show because nothing has happened yet. Full size, with icon + description + primary CTA.

No agents yet

Create your first voice agent to start handling calls.

<EmptyState
variant="blank-slate"
icon={<IconInbox className="size-10 text-muted-foreground" />}
title="No agents yet"
description="Create your first voice agent to start handling calls."
action={<Button>Create your first agent</Button>}
secondaryAction={<Button variant="outline">Import from CSV</Button>}
/>

Filtered

Data exists, the filters hide it. Compact layout, no icon, single CTA: clear filters.

No agents match these filters

<EmptyState
variant="filtered"
title="No agents match these filters"
action={<Button variant="outline">Clear filters</Button>}
/>

Permission

The user can’t see this. Lock icon + an “ask for access” CTA. Don’t suggest creating things they aren’t allowed to.

You don't have access to this page

Members management is admin-only. Ask a workspace admin to invite you.

<EmptyState
variant="permission"
icon={<IconLock className="size-10 text-muted-foreground" />}
title="You don't have access to this page"
description="Members management is admin-only. Ask a workspace admin to invite you."
action={<Button>Request access</Button>}
/>

Error

Fetch failed. Alert icon + retry CTA. Keep the description short and concrete; defer the trace ID to a separate detail line if needed.

Couldn't load agents

Check your connection and try again.

<EmptyState
variant="error"
icon={<IconAlertCircle className="size-10 text-destructive" />}
title="Couldn't load agents"
description="Check your connection and try again."
action={<Button>Retry</Button>}
/>

API

Prop Type Default Description
variant "blank-slate" | "filtered" | "permission" | "error" "blank-slate" Reason this region is empty. Drives layout, icon visibility, and copy expectations.
title * string Headline. Lead with the noun, sentence case.
description string One-sentence elaboration. Skip in filtered variant.
icon ReactNode | string Pass an icon component (preferred) or a single emoji. Hidden in filtered variant.
action ReactNode Primary CTA. Pass a <Button>.
secondaryAction ReactNode Optional second CTA next to action.
className string Forwarded.

Design guidelines

✓ Do

  • Match the variant to the reason: blank-slate (new), filtered (hidden by query), permission (denied), error (failed).
  • Lead the title with the noun: "No agents yet", "No results match these filters".
  • Make the primary action solve the problem stated. "Clear filters" for filtered. "Retry" for error.

✗ Don't

  • Show a "Create your first" CTA on a permission-denied surface.
  • Add an icon to a filtered-zero state. The pattern is intentionally compact.
  • Stack three CTAs. One primary, optionally one secondary, never more.

Accessibility

  • Title renders as <h2> — slot it into a region that doesn’t already own the page heading. EmptyState inside a <section> is correct; inside a <main> next to the page <h1> is fine.
  • Color of the error icon is text-destructive, but the message (“Couldn’t load…”) carries the meaning — color is never the only signal.
  • Action buttons are real <Button>s with full keyboard support — don’t pass styled <div>s.
  • Alert — Inline persistent message inside a region with content.
  • Sonner — Transient feedback after an action.
  • Skeleton — Use before a fetch resolves; swap to EmptyState error if it fails or blank-slate if it succeeds with no rows.

▶ Open EmptyState stories in Storybook