EmptyState
Centered surface for "no data here" — empty lists, search results, tables, dashboards, inboxes, 404s, first-run / onboarding moments. Four variants, four sizes, semantic intents tinting the icon tile, primary + secondary actions, optional footer, heading control, LTR/RTL.
pnpm add @tessinaui/uiWhen to use
- A region with nothing to show yet. An empty list, table, inbox or dashboard, or a first-run view where the feature has not been used.
- A search or filter with no matches. Say what was searched and offer a way back, such as clearing filters.
- Content that was removed or is out of reach. Explain why the space is empty, and what the person can do about it.
- A way forward, not a dead end. Give one primary
actionand keepsecondaryActionquieter; leave both out when nothing applies. - A positive "all done". A cleared queue is also an empty state, and its copy can be calm rather than apologetic.
When not to use
- While data is loading. Use Skeleton, or
EmptyStateSkeletonto hold the empty state's own box. - For an error on a page that still shows content, or one that needs immediate action. Use Alert or Banner; an empty state is for the region that has no content (see the comparison below).
- For the "+ New project" tile in a grid. That is a create button;
variant="dashed"is the region shape, not the grid tile. - For a small or incidental gap. A single empty card or a one-line "none" needs plain text, or the minimal shapes in Minimal.
- For a multi-step first-run flow. Link to your own onboarding from the
actioninstead of stacking steps in the empty state.
Installation
pnpm add @tessinaui/uiUsage
import { EmptyState } from "@tessinaui/ui";{/* Empty inbox */}
<EmptyState
icon={<Inbox />}
title="Inbox is empty"
description="When new messages arrive, they'll show up here."
action={{ label: "Compose", leadingIcon: <Plus /> }}
/>
{/* No search results — error intent */}
<EmptyState
icon={<SearchX />}
intent="error"
title="No matching results"
description="Try a different search term, or clear filters to see everything."
action={{ label: "Clear filters", variant: "outline", intent: "none" }}
secondaryAction={{ label: "Reset search" }}
/>
{/* Drop zone — dashed variant */}
<EmptyState
variant="dashed"
icon={<Upload />}
title="Drop files here"
description="PNG, JPG, PDF up to 10 MB. Or click to browse."
action={{ label: "Choose files" }}
/>
{/* 404 / not found */}
<EmptyState
size="lg"
image={
<div className="size-20 rounded-full bg-error-light flex items-center justify-center">
<CloudOff className="size-10 text-error" />
</div>
}
title="Page not found"
description="The page you're looking for has been moved or no longer exists."
action={{ label: "Go home" }}
secondaryAction={{ label: "Contact support" }}
/>Examples
Default
A simple empty state with an icon, title, description, and a primary action.
Variants
Four surface styles — plain, card, dashed, and soft (a borderless muted
band). On soft, muted text steps to
text-foreground/75 and the neutral icon tile to bg-background, so both
stay AA on the tinted plate.
Sizes
Four sizes — sm, md, lg, and xl — scale padding, icon tile, and type.
Intents
intent tints the leading icon tile while title and description stay neutral.
With actions
A primary action, a quieter secondary action, and a footer for alternative paths.
Loading
EmptyStateSkeleton reserves the same vertical rhythm while content loads.
It is silent by default (house skeleton policy) — pass label="Loading…"
to opt into a single role="status" announcement.
Minimal
The quiet end of the range — text-only, bare icon + caption, and the compact
start-aligned in-panel state with a link-weight action. All are field-dominant
shapes; icon, title, description and actions are each optional.
Illustration
image replaces the icon tile with a custom illustration. The media slot caps
itself at max-w-full, so a wide graphic cannot overflow a narrow column.
Start-aligned
align="start" anchors copy and actions to the leading edge. Pair with
titleAs when the empty state is the sole content of a region and its title
belongs in the page outline.
Error and offline
Error/not-found/offline presets ship as recipes, not exports — intent
plus your copy covers everything a dedicated ErrorEmptyState component would
hard-code. Three action tiers: filled retry, ghost secondary, and a
footer for the error ID + support link.
On color
A plain empty state paints no panel, so inside a coloured Surface its title, description and action read the plate. card, dashed and soft paint a page panel and keep the page's look.
RTL
With align="start", dir="rtl" anchors the icon, copy and actions to the right edge, and the primary action comes first on the right. With dir unset the empty state inherits the page's direction.
EmptyState vs Banner vs Alert
| EmptyState | Banner | Alert | |
|---|---|---|---|
| Purpose | "No data here" — fills a content area | Promotional / informational — opt-in marketing | Reactive feedback — system event |
| Layout | Always centered (or start), fills container | Inline / centered / landscape, rides above content | Inline, tight |
| Container | Plain by default; card or dashed chrome | Always has surface chrome | Tight bordered/filled card |
| Examples | Empty inbox, no search results, 404, first-run | "Unlock Premium", "Join our rewards programme" | "Failed to save", "Update available" |
| Default role | none (static page content) | region | derived per intent |
Two boundaries: an error that needs immediate
action is a Banner/Alert, not an EmptyState; and the "+ New
project" dashed create-tile in a project grid is a ghost create-button, not
this component — variant="dashed" is the region shape, not the grid tile.
API Reference
Props
| Prop | Type | Default | Description |
|---|---|---|---|
title | ReactNode | — | Headline |
titleAs | "div" | "p" | "h2"–"h6" | "div" | Element the title renders as. Set an h2–h6 when the empty state is the sole content of a region, so it joins the page outline (same titleAs shape as the house Alert). |
description | ReactNode | — | Subhead / body copy. Has size-aware max-w so long copy wraps cleanly. |
variant | "plain" | "card" | "dashed" | "soft" | "plain" | Surface chrome — plain for centered content with no border, card for a bordered surface, dashed for drop zones, soft for a borderless muted band |
size | "sm" | "md" | "lg" | "xl" | "md" | Scales padding, gap, icon tile size, and font sizes |
intent | "none" | "error" | "warning" | "success" | "info" | "none" | Tints the leading icon tile. Title and description stay neutral. ("primary" is deprecated for one minor: it is not a semantic intent — use "none".) |
rounded | "none" | "sm" | "md" | "lg" | "full" | "lg" | Corner radius of the container (card, dashed, soft), the icon tile and the default actions. full caps the container at the largest panel radius and makes the icon tile a circle. |
align | "center" | "start" | "center" | Content alignment |
icon | ReactNode | — | Small leading icon — wrapped in a tinted square tile |
image | ReactNode | — | Larger illustration / image. Replaces icon when both passed. |
action | EmptyStateAction | ReactNode | — | Primary CTA |
secondaryAction | EmptyStateAction | ReactNode | — | Quieter secondary CTA — defaults to ghost |
footer | ReactNode | — | Small print, alternative paths, links |
dir | "ltr" | "rtl" | inherited | Text direction |
role | AriaRole | — | No default. Pass role="status" only for a state that swaps in dynamically — and note a polite region must be mounted empty first to announce at all (see Accessibility). |
className | string | — | Additional classes on the root |
EmptyStateAction
When you pass an object, the empty state renders a <Button> for you with sensible defaults. When you pass a ReactNode, that node is rendered as-is — useful for SplitButton, IconButton, custom links, etc.
| Field | Type | Description |
|---|---|---|
label | ReactNode | Button text |
onClick | (event) => void | Click handler |
href | string | When set, the button renders as <a href> via Button's render prop |
variant | "primary" | "secondary" | "ghost" | "outline" | Button variant |
intent | "none" | "error" | "warning" | "success" | "info" | Button intent |
size | "xs" | "sm" | "md" | "lg" | "xl" | Button size |
rounded | "none" | "sm" | "md" | "lg" | "full" | Button corner radius |
loading | boolean | Show spinner |
disabled | boolean | Disabled state |
leadingIcon / trailingIcon | ReactNode | Icons inside the button |
className | string | Extra classes merged onto the rendered button |
EmptyStateSkeleton
Pass the same size, variant, rounded and align as the empty state it stands in for, and the placeholder is the same box — icon tile, title and description line boxes, and action buttons included.
| Prop | Type | Default | Description |
|---|---|---|---|
size | "sm" | "md" | "lg" | "xl" | "md" | Match the empty state's size |
variant | "plain" | "card" | "dashed" | "soft" | "plain" | Match the empty state's variant. On soft the bars take an ink that shows on the plate |
rounded | "none" | "sm" | "md" | "lg" | "full" | "lg" | Match the empty state's rounded |
align | "center" | "start" | "center" | Match the empty state's align |
showIcon | boolean | true | Icon tile placeholder |
lines | number | 2 | Description lines under the title |
showAction | boolean | true | Primary action placeholder |
showSecondaryAction | boolean | false | Secondary action placeholder |
showFooter | boolean | false | Footer line placeholder |
label | string | — | Opt-in loading announcement. Unset, the skeleton is aria-hidden; set, it is one role="status" |
dir | "ltr" | "rtl" | inherited | Text direction |
Notes
- Action defaults: when you pass an
EmptyStateActionobject, itsvariant,intent,size, androundeddefault to sensible values for the empty state'sintent/size. The common case (<EmptyState action={{ label: "Create" }} />) needs no extra tuning. - Icon vs image:
iconbecomes a tinted square tile coloured byintent.imageis rendered as-is — pass any element. When both are passed,imagewins. - Description sizing: description has size-aware
max-w(max-w-xstomax-w-lg) and usestext-balancein centered alignment so long copy wraps into nice symmetric lines instead of one long ragged line. - Variants: most empty states should be
plain— they fill an existing content area that already has its own surface. Usecardwhen the empty state needs to stand alone (e.g., as a hero on a blank dashboard). Usedashedfor drop zones / "click to upload" affordances. - RTL — works automatically via
dir="rtl". Icon tile, text alignment, and action ordering all flip. - Copy — write the title as a short, positive statement of what goes here ("Start by adding data assets"), not what's missing, and don't word it like a button — a title isn't tappable, and a verb phrase in heading position invites a tap that does nothing.
Accessibility
EmptyState is static page content, so it has no APG widget pattern. What it announces, and when, is a decision you make per placement.
- No role by default. A
role="status"that mounts with its content already in place announces nothing (polite regions only speak on content change), and a live region wrapping the action buttons would announce their internal churn. - For a state that swaps in after a fetch, pass
role="status"and make sure the region exists (empty) before the content lands — otherwise it stays silent. An error that needs immediate action belongs in an Alert, not here. - Headings are opt-in. The title defaults to a non-heading
div, like the house Alert. SettitleAs="h2"–"h6"when the empty state is the sole content of a region, so it joins the page outline. - Media is decorative. The icon tile is
aria-hidden: it repeats what the title already says. Animageyou pass is rendered as-is — give a meaningful illustration its ownalt, and a decorative onealt="". - Meaning never rides on the tint.
intentcolours only the icon tile; title and description stay neutral, so the words carry the state. axecolor-contrastis clean on the demo and playground in both themes. - Actions are ordinary buttons and links — object actions render a
Button(an<a>whenhrefis set) with its 44px touch floor on phones. EmptyStateSkeletonis silent unless you passlabel, which turns it into a singlerole="status"whose visually hidden label is written 150ms after mount. There is noaria-busyon it: VoiceOver + Safari never speaks a busy status region.
Keyboard
| Key | Behaviour |
|---|---|
Tab / Shift+Tab | Moves through action, secondaryAction, then any links in footer — natural order, no trap |
Enter / Space | Activates the focused action (Enter only for a link) |
AT matrix
| AT | Status |
|---|---|
| VoiceOver + Safari (macOS) | Passed 2026-09-14. Reads "No items", "Items you create will show up here.", "Refresh, button", "Need help?", "link, Read the guide" — no role of its own. Transcript: tests/qa/vo-transcripts/empty-state.md |
| VoiceOver + Chrome (macOS) | UNTESTED |
| NVDA + Chrome (Windows) | UNTESTED — no Windows host available |
| JAWS | UNTESTED |
Status stays beta until one VoiceOver and one NVDA pass are on record.