Patterns

Empty states

What to show in a slot that has no data yet — a blank space is a missed chance to help. Keep it friendly and direct: say what the slot is waiting for, then point at the next step. No apology, no blame — an empty state is a welcome mat, not an error.

BetaThe EmptyState component is Beta. The slot, its variants, and the copy formula below are settled; the per-feature copy library, the illustration treatment policy, and a live permission-empty example are pending and will be authored as the patterns appear in product.

Anatomy

The pattern's component is EmptyState — a centered column that mounts inside whatever container owns the data, usually a Card. The container supplies the surface and the frame; the slot carries the column. Four parts, in order; only the heading is mandatory.

text
container (a Card, a table body, a panel — supplies surface + frame)
  EmptyState   the slot — a centered column; variant names the fact
    icon       decorative, aria-hidden (optional)
    heading    system voice — names what's missing (required)
    children   one sentence — the next action
    action     THE one action (optional for filtered-empty)

The heading speaks in the system's slab-serif display voice — flat type on a flat, friendly surface (the engraved treatment belongs to the proposed system). The body is one warm sentence: the next step. One action is enough; don't list five options.

First-run empty

Nothing here yet because the user hasn't created anything — which makes this the friendliest moment in the product. Name what the slot is waiting for and hand over the first step.

First-run

No evidence attached

Upload a file or link a system to get started.

A Card-owned slot: a friendly icon on its color disc, a display-voice heading naming the gap, one sentence, one action.

Filtered to empty

Data exists, but the active filters exclude all of it. The fact changes — name the filters, not the data — and the next action widens the set instead of creating something. The clear-filters control is a quiet secondary action, not a primary CTA.

Filtered-empty
Vendor attestations · 3 filters active

No results match these filters

Try removing a filter or two to widen the set.

Live: clearing the filters brings the rows right back; the region announces the change politely. The slot never scolds anyone for the filters they picked.

Empty is not an error

An empty state is a fact about the data — there is none. An error state is a fact about the attempt — the system could not find out. If the query failed, you do not know the slot is empty; rendering the empty state over a failed fetch tells the user a falsehood. Route failures to /error-states instead.

Empty vs error
Empty — the data is absent

No findings recorded

Run a check to populate this view.

Error — the answer is unknown
Section error

Couldn't load findings

Try again. If it keeps failing, the rest of the page is still working — move on and we'll log this on our side.

Same slot, two different facts. The empty card invites the first action; the error card names the failure and offers a retry. Loading is a third, earlier fact — see the loading pattern.

EmptyState and ErrorState are deliberately separate components, not variants of one. They share only the idea of a centered column; everything that matters differs — empty is a neutral fact with no role and no severity color, error is role="alert" in the critical register with a retry shape. Merging them would couple an invitation to an alarm.

Variations

First-run empty
Nothing created yet. Primary action: create or attach the first item.
Filtered-empty
Results exist outside the active filters. Secondary action: clear filters.
Permission-empty
The feature is gated and the user has not unlocked it. Secondary action: learn more. No live example yet — it will be authored when a gated surface ships.

Copy formula

Every empty state derives from the same clear-is-kind formula: a noun phrase naming what's empty, then an imperative naming the next action.

text
<Noun phrase naming what's empty>. <Imperative naming the next action>.
  • Do"No evidence attached. Upload a file or link a system to begin."
  • Don't"Sorry, you haven't attached any evidence yet." — apology plus blame; neither helps the reader act.
  • Don't"There's nothing here right now." — names nothing and points nowhere.

Accessibility

  • Heading order. The slot heading is a real heading element at the level the page structure expects — assistive tech finds the empty slot the same way sighted users do.
  • Decorative icon. The icon is aria-hidden; it repeats the heading, it never carries it. Severity glyph pairing (ADR-0017) applies on the error side of the boundary — an empty state has no severity.
  • One real action. The CTA is a canonical Button: keyboard-focusable with a visible focus ring. Don't make the whole slot clickable.
  • Announce filter changes. When clearing filters swaps the slot for rows, the region is aria-live="polite" — not role="alert"; nothing went wrong.
  • Reduced motion. The slot's entrance animation is skipped under prefers-reduced-motion.

API

EmptyState owns the slot — the column, the register, the settle entrance, reduced-motion, and the one-action rule by construction. The container it mounts in stays yours, and so does the live-region wiring: the component renders no role and no aria-live, because only the consumer knows which region changes.

heading: ReactNode
Required — the slot always states the fact. System voice: a noun phrase naming what's missing.
children?: ReactNode
The body — one plain sentence naming the next action.
action?: ReactNode
THE one action, a canonical Button. Singular by design — the one-action rule is enforced by the prop shape, not by review.
icon?: ReactNode
Decorative Lucide glyph (strokeWidth 1.75), rendered on its friendly color disc and always aria-hidden — it repeats the heading, never carries it.
variant?: 'first-run' | 'filtered' | 'permission'
Which empty fact this is. Defaults to first-run. Filtered sits tighter — it lives inside a working list region, not a first-run hero slot.
headingAs?: 'h2' | 'h3' | 'h4'
The heading element, so the slot lands at the level the page structure expects. Defaults to h3.

Usage

Mount EmptyState inside the container that owns the data — the slot belongs to whoever owns the data, so the Card (or table body, or list region) stays in your hands.

First-run empty inside a Cardtsx
import { Inbox, Upload } from 'lucide-react'
import { Button, Card, EmptyState } from '@halo-compliance/ui'

<Card variant="secondary">
  <Card.Body>
    <EmptyState
      icon={<Inbox strokeWidth={1.75} />}
      heading="No evidence attached"
      action={
        <Button
          variant="primary"
          icon={<Upload strokeWidth={1.75} aria-hidden="true" />}
        >
          Upload evidence
        </Button>
      }
    >
      Upload a file or link a system to begin.
    </EmptyState>
  </Card.Body>
</Card>

Anti-patterns

  • Don't apologize or blame. "Sorry" and "you haven't" both spend the reader's attention on feelings instead of facts. State what is, then what to do.
  • Don't show it while loading. An empty state asserts the data is absent. Until the query resolves, that's unknown — show the loading state, then decide.
  • Don't stack actions. One next action. A slot with five buttons is a navigation problem wearing an empty state's clothes.
  • Don't throw confetti. Fun lives in the surfaces, not in overclaiming. An empty review queue is a quiet win — "You're all caught up. No findings need review." says it warmly without the fireworks.
  • /error-states — the other side of the boundary: the attempt failed.
  • /loading — what to show before you know whether the slot is empty.
  • /card — the container that owns most empty slots.
  • /button — the one action the slot offers.