EmptyState
The canonical layout for empty lists, no-results, first-run, error, success, and loading surfaces. Drop it anywhere the data could be absent: data tables, search results, dashboards, file lists, inboxes.
EmptyState is two APIs in one: the prop-driven shortcut form for the 80% case, and a
compound form (<EmptyState.Icon> / .Illustration / .Title / .Description / .Actions)
for full control. When any compound child is detected, the prop-driven shortcuts are silently
ignored — the two modes never compete.
Overview — icon, copy, and a clear next step
Anatomy
┌─────────────────────────────────────────────────────┐
│ ◯ │ ← <EmptyState.Icon> / .Illustration
│ │
│ No users yet │ ← <EmptyState.Title> (<h3> by default)
│ Invite your team to collaborate. │ ← <EmptyState.Description> (<p>)
│ │
│ [Invite teammates] [Learn more] │ ← <EmptyState.Actions> (flex row)
└─────────────────────────────────────────────────────┘┌─────────────────────────────────────────────────────┐
│ ◯ │ ← <EmptyState.Icon> / .Illustration
│ │
│ No users yet │ ← <EmptyState.Title> (<h3> by default)
│ Invite your team to collaborate. │ ← <EmptyState.Description> (<p>)
│ │
│ [Invite teammates] [Learn more] │ ← <EmptyState.Actions> (flex row)
└─────────────────────────────────────────────────────┘Variants
| Variant | Root role | Icon container tint | Auto glyph |
|---|---|---|---|
default | region (with aria-labelledby → Title) | neutral subtle | — |
error | alert | danger-subtle bg + danger fg | — |
loading | status + aria-busy="true" + aria-live="polite" | neutral subtle | <Spinner /> |
success | region | success-subtle bg + success fg | — |
The loading variant auto-injects a <Spinner /> in the icon slot only when neither
icon nor illustration is supplied (nor a compound <EmptyState.Icon> child). Pass an
explicit icon to override.
Sizes
| Size | Icon container | Title text | Padding (y / x) | When |
|---|---|---|---|---|
sm | 40 × 40 px | text-base | 24 / 16 px | Inline use, sidebar panels. |
md | 56 × 56 px | text-lg | 40 / 24 px | Default. Page-level empty states. |
lg | 80 × 80 px | text-xl | 64 / 32 px | Hero empty surfaces, full-page. |
Action shortcuts
Both primaryAction and secondaryAction accept the same shape:
type ActionShortcut = {
label: ReactNode;
onClick?: () => void;
href?: string; // when set, renders as <Button asChild><a href=…>
target?: string; // anchor target, only meaningful with href
rel?: string; // anchor rel, only meaningful with href
variant?: 'solid' | 'outline' | 'ghost'; // default: primary='solid', secondary='ghost'
color?: 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'neutral';
// …all other <Button> props
};type ActionShortcut = {
label: ReactNode;
onClick?: () => void;
href?: string; // when set, renders as <Button asChild><a href=…>
target?: string; // anchor target, only meaningful with href
rel?: string; // anchor rel, only meaningful with href
variant?: 'solid' | 'outline' | 'ghost'; // default: primary='solid', secondary='ghost'
color?: 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'neutral';
// …all other <Button> props
};When target="_blank" is set without an explicit rel, the component injects
rel="noopener noreferrer" automatically.
Examples
Minimal — title + description only
With an icon
With a larger illustration
Adding a primary call-to-action
Primary + secondary (the secondary is a link)
variant=error — role=alert, danger-tinted icon
variant=loading — auto-injects <Spinner />, role=status
variant=success — celebratory tone
size=sm — for inline use
bordered — fills a rect inside a DataGrid / FileUpload
Compound API for full content control
align=start — left-aligned for sidebar panels
Props
<EmptyState />
| Prop | Type | Default | Notes |
|---|---|---|---|
variant | 'default' | 'error' | 'loading' | 'success' | 'default' | Drives ARIA role + icon tint + (loading) auto-Spinner. |
size | 'sm' | 'md' | 'lg' | 'md' | Density token; cascades to all subparts via context. |
align | 'center' | 'start' | 'center' | Layout alignment. start is for inline use. |
bordered | boolean | false | Wraps in a dashed rounded border — for DataGrid empty slots. |
padded | boolean | true | Adds outer padding sized to size. Disable for parent-owned spacing. |
as | 'section' | 'div' | 'aside' | 'article' | 'section' | Root element. <section> is a landmark. |
icon | ReactNode | — | Small glyph rendered in the icon container. |
illustration | ReactNode | — | Larger graphic; wins over icon when both are set. |
title | ReactNode | — | Becomes the accessible label. |
description | ReactNode | — | Becomes the accessible description. |
primaryAction | ActionShortcut (see above) | — | Solid Button by default. |
secondaryAction | ActionShortcut | — | Ghost Button by default. |
className | string | — | Merged via tailwind-merge. |
sx | Sx | — | Theme-aware inline style. |
Subcomponents
| Subcomponent | Element | Notes |
|---|---|---|
<EmptyState.Icon> | <div> | Tinted circular container; aria-hidden="true". |
<EmptyState.Illustration> | <div> | Max-width sized per size; aria-hidden="true". |
<EmptyState.Title> | <h3> (default; override via as) | The accessible name of the region. |
<EmptyState.Description> | <p> | The accessible description. |
<EmptyState.Actions> | <div> | Flex row for <Button> children. |
All five subparts pull size / variant / align from EmptyStateContext.
Accessibility
- Variant decides the role.
default/success→region.error→alert.loading→status+aria-busy="true"+aria-live="polite". - Title is the accessible name. When prop-driven, the root auto-wires
aria-labelledbyto the auto-rendered<h3>. Compound mode + custom Title with an explicitidwill be picked up too if the consumer passes<EmptyState aria-labelledby={myId}>. - Description is the accessible description. Same
aria-describedbypattern. - Icon / illustration are decorative. Always
aria-hidden="true"so they don't duplicate the title in the AT tree. - Actions are real buttons.
<Button>/<Button asChild><a/>— full keyboard support, focus ring, native semantics. hrefactions are safe by default.target="_blank"withoutrelinjectsrel="noopener noreferrer".- axe-core. Zero violations across
variant × size × align, including compound mode and the auto-Spinner loading variant.
Theming
defineTheme({
components: {
EmptyState: {
defaultProps: { size: 'lg', bordered: true },
styleOverrides: {
root: 'bg-bg-paper',
icon: 'shadow-sm',
title: 'tracking-tight',
description: 'leading-loose',
actions: 'gap-3',
},
},
},
});defineTheme({
components: {
EmptyState: {
defaultProps: { size: 'lg', bordered: true },
styleOverrides: {
root: 'bg-bg-paper',
icon: 'shadow-sm',
title: 'tracking-tight',
description: 'leading-loose',
actions: 'gap-3',
},
},
},
});Each of the six slots (root / icon / illustration / title / description / actions)
is themable independently. defaultProps applies under both the prop-driven and compound APIs.
Integration patterns
| Surface | Recommended composition |
|---|---|
| Data table | <EmptyState size="sm" bordered icon={…} title="No rows" primaryAction={{ label: 'Add row', … }} /> |
| Search page | <EmptyState illustration={…} title="No results" primaryAction={{ label: 'Clear filters' }} /> |
| Dashboard | <EmptyState size="lg" icon={…} title="No data yet" primaryAction={{ label: 'Connect a source' }} /> |
| Error boundary | <EmptyState variant="error" title="Something went wrong" primaryAction={{ label: 'Retry' }} /> |
| Suspense fallback | <EmptyState variant="loading" title="Loading workspace" /> |
| First-run | <EmptyState size="lg" illustration={…} title="Welcome!" primaryAction={{ label: 'Get started' }} /> |
DataGrid, FileUpload, and Combobox can consume EmptyState as their default empty / no-results slot content.