No evidence attached
Upload a file or link a system to get started.
Patterns
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.
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.
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.
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.
Upload a file or link a system to get started.
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.
Try removing a filter or two to widen the set.
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.
Run a check to populate this view.
Try again. If it keeps failing, the rest of the page is still working — move on and we'll log this on our side.
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.
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.
<Noun phrase naming what's empty>. <Imperative naming the next action>.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.
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.
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>