Skip to content
Navigation

Empty State

Centered placeholder for a region with nothing in it, with room for the action that fills it. Upstream calls this component `empty`.

Added 14 October 2026

Two situations, one component

  • Nothing created yet — offer the action that creates the first thing. This is the more valuable case: an empty state here is onboarding, not an error.
  • Nothing matched — offer the way back. Clear the filters, widen the range. Say which constraint is probably responsible.

If neither applies — the data failed to load — that is a Notice, not an empty state. An empty state says “there is nothing”, which is a fact about the data; a failure says “we don’t know”, which is a fact about us.

Announce it when it replaces content

This is the one thing that is easy to miss. An empty state appearing after a filter change is a status message: focus stays on the filter control, the result list silently vanishes, and a screen-reader user gets nothing. That is WCAG 4.1.3.

<EmptyState role="status">…</EmptyState>
<hlm-empty-state role="status">…</hlm-empty-state>

The role is not baked in, deliberately: an empty state that is the initial content of a page must not be a live region — it would announce on load, competing with the page title. One prop, two situations, and the choice is yours to make per use. [a11y]

Composition

<EmptyState className="border">
  <EmptyStateHeader>
    <EmptyStateMedia variant="icon"><FolderPlusIcon /></EmptyStateMedia>
    <EmptyStateTitle>No campaigns yet</EmptyStateTitle>
    <EmptyStateDescription>Create your first campaign to start booking inventory.</EmptyStateDescription>
  </EmptyStateHeader>
  <EmptyStateContent className="flex-row justify-center gap-2">
    <Button>Create campaign</Button>
    <Button variant="outline">Import</Button>
  </EmptyStateContent>
</EmptyState>
  • EmptyStateMedia takes variant="icon" for the muted rounded well, variant="icon-lg" for a size-16 circular well suited to section-level empty states (a whole tab or overview pane, not a slot in a card), or the default for a bare icon or an illustration.
  • The icon-lg well is deliberately neutral (same text-foreground as icon); if a section has an accent colour, tint the icon by setting the text colour on the media — <EmptyStateMedia variant="icon-lg" className="text-brand-interactive"> / <hlm-empty-state-media variant="icon-lg" class="text-brand-interactive"> — never the well’s bg-muted fill. The colour must sit on the media, not the icon element: @ng-icons’ unlayered host rule beats colour utilities placed on the ng-icon itself, and currentColor inheritance works identically in both frameworks. The icon is decorative (both icon libraries mark it aria-hidden), so a neutral dim like opacity-40 is fine too — but dim or tint, never both: 40% opacity drops an accent below visibility on the dark theme’s muted fill.
  • EmptyStateContent stacks by default; add flex-row for a pair of buttons side by side.
  • The container ships a dashed border colour but no border width, so className="border" opts into the dashed outline and gets the neutral hairline rather than currentColor — this package has no global border-color reset. Without border there is no visible edge and the box is delimited by whitespace, which is the right default inside a card.

Heading level: in Angular hlmEmptyStateTitle is a directive, so put it on the element you want — <h2 hlmEmptyStateTitle> when the empty state replaces a whole titled region. In React the part renders a <div>; use your own heading and the title’s classes if the document outline matters there.

Rules

  • One primary action, at most one secondary. An empty state with four buttons is a menu.
  • The description says what the action will do, not that the list is empty — the title already said that.
  • Keep it inside the region it describes, at the region’s size. A full-page empty state for an empty table cell reads as a crash.
  • Icons here are decorative; both icon libraries mark them aria-hidden by default, so the message must stand on its own text. It does — don’t add an aria-label to the media. [a11y]

Never

  • An empty state for a loading state — use Skeleton.
  • An empty state for a failure — use Notice and say what failed.
  • role="status" on an empty state that is present on first render.