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.
Playground
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, the Webflow/NotebookLM strip shape). 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 what Carbon's ErrorEmptyState/NotFoundEmptyState
components hard-code. Three action tiers: filled retry, ghost secondary, and a
footer for the error ID + support link.
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", "7-Eleven loyalty" | "Failed to save", "Update available" |
| Default role | none (static page content) | region | derived per intent |
Two boundary notes from the research set: an error that needs immediate
action is a Banner/Alert, not an EmptyState (Astryx's rule); and the "+ New
project" dashed create-tile in a grid (Framer, Replit, Frame.io) 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 (Atlassian headingLevel shape; house Alert titleAs precedent). |
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 a deprecated alias for one minor — §1 has no primary intent.) |
rounded | "none" | "sm" | "md" | "lg" | "full" | "lg" | Container border radius. full caps at the §2 panel radius; the icon tile becomes 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 |
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 (Carbon + Material guidance).
- Accessibility — the root carries no role by default: no surveyed system mints a live region here, 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, passrole="status"and make sure the region exists (empty) before the content lands. The icon tile isaria-hidden(decorative — the Astryx/Carbon consensus); the title defaults to a non-headingdivlike the house Alert — settitleAsfor page-outline semantics.EmptyStateSkeletonis silent unless you passlabel.