Empty States
The most expensive screen in a product is the one with nothing on it and no way forward. Four different kinds of empty, four different jobs.
Also called Zero State, Blank Slate, No Data — in this system all of them are Empty States.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
No projects yet
A project holds your services, environments and deployments. Most teams start with one per repository.
The four kinds
Same component, four different jobs. Note how the action changes: create, widen, review, escalate.
No projects yet
A project holds your services, environments and deployments. Most teams start with one per repository.
No deployments match “gateway-v3”
Try a shorter search term, or clear the environment filter to widen the results.
Inbox zero
Everything is handled. New incidents will appear here as they are opened.
You do not have access to this project
Ask an administrator to add you, or switch to a workspace you are a member of.
Inline and compact
An empty table body, an empty sidebar section, an empty card. The compact variant halves the vertical padding so it does not dwarf its container.
First run as onboarding
The most valuable empty state in any product. It gets read once, by every new user, at the exact moment they are deciding whether to continue.
Deploy your first service
Connect a repository and we will build and deploy it on every push. Most teams are live in under four minutes.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
No projects yet
No deployments match “
Inbox zero
You do not have access
Could not load deploym
Nothing here
Inbox zero
Everything handled.
Empty
Every part, every measurement, and the reason it is that number.
No projects yet
A project holds your services, environments and deployments.
Icon, title, description, primary action, secondary action. Everything is centred, and the whole block is capped at 24rem so it never spans a wide container.
- Icon container56px, 16px radius
A tinted square, not a large illustration. Big enough to anchor the block, small enough to keep the action above the fold.
- Vertical padding64px, 40px compact
Generous, because the emptiness is the point — a cramped empty state reads as a rendering failure rather than a designed state.
- Title19px · text-h3
A real heading element, so screen-reader users landing in the region understand it immediately.
- Description widthmax 24rem
About 45 characters per line. Centred text needs a narrower measure than left-aligned, because every line starts in a different place.
- Action gap16px below the text
Close enough to read as the answer to the description. A large gap makes the action look like a separate, optional thing.
- Secondary actionText variant, beside
One primary and at most one secondary. A third action turns guidance into a menu.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-surface-inset | — | Icon container fill |
| --ds-border-subtle | — | Icon container border |
| --ds-fg-muted | — | Icon and description |
| --ds-accent-subtle | — | Icon fill on an onboarding state |
| --ds-danger-subtle | — | Icon fill on an error state |
Spacing
| Token | Value | Used for |
|---|---|---|
| padding-y | Vertical breathing room | |
| gap | Between icon, text and actions |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-xl | Icon container |
Typography
| Token | Value | Used for |
|---|---|---|
| --text-h3 | — | Title |
| --text-body-sm | — | Description |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Padding | Icon | Label gap | Max width | When to use |
|---|---|---|---|---|---|
| Compact | 40px 24px | 44px container | 12px | 24rem | Inside a table body, a card, or a sidebar section. |
| Default | 64px 32px | 56px container | 16px | 24rem | A full page region — the main content area of a screen. |
| Onboarding | 56px 32px | 56px container | 20px | 28rem | First run. Room for two actions and a documentation link. |
| Inline | 12px 0 | — | — | — | One line of muted text. No icon, no action, for genuinely unremarkable emptiness. |
No projects yet
No deployments match “gateway-v3”
Try a shorter term, or clear the environment filter.
Deploy your first service
Connect a repository and we build and deploy on every push.
Oops! Nothing to see here 🙈
…the user has been denied access to their own project
Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- The description uses --ds-fg-muted, the tightest pair in the system at 4.6:1. Do not go lighter for a "softer" empty state.
- The icon is decorative and exempt, but if it carries meaning — an error icon — it must reach 3:1.
Keyboard
| Tab | Reaches the primary and secondary actions. The block itself is not focusable. |
| Enter | Activates the focused action. |
Screen readers
- A sighted user sees the list is empty at a glance. A screen-reader user needs to be told — announce "No results" through the live region on the results container.
- The heading gives the region a name in the landmarks list, which is how screen-reader users navigate between regions.
- Do not announce the description as well as the title on every filter change. The title alone is enough.
Focus & touch
- After a search returns nothing, keep focus in the search field. Moving it to the empty state means the user has to tab back to correct their query.
- The primary action must be at least 44px and centred, which puts it in easy reach on mobile. Keep the secondary action below rather than beside it on narrow screens.
| Attribute | Applied to | Notes |
|---|---|---|
| <h2> or <h3> | The title | A real heading, so the region has an entry in the document outline. |
| aria-hidden="true" | The icon | Decorative. The title carries the meaning. |
| role="status" | A no-results state | So the change from 12 results to 0 is announced after a filter or search. |
| aria-live="polite" | The results region | Announce the count, not the whole empty state, on every filter change. |
Example usage
1import { EmptyState } from '@/ui/Feedback'23// Always distinguish the four kinds — they need different actions4function DeploymentList({ items, query, filters, loading, error }) {5 if (loading) return <DeploymentSkeletons /> // never "empty"6 if (error) return <ErrorState error={error} onRetry={refetch} />78 if (items.length === 0) {9 const filtered = query || filters.length > 01011 return filtered ? (12 <EmptyState13 icon={<SearchX size={22} />}14 title={'No deployments match “' + query + '”'}15 description="Try a shorter term, or clear the environment filter."16 action={<Button onClick={clearFilters}>Clear filters</Button>}17 />18 ) : (19 <EmptyState20 icon={<FolderPlus size={22} />}21 title="No deployments yet"22 description="Connect a repository and we will build and deploy on every push."23 action={<Button onClick={connect}>Connect a repository</Button>}24 secondaryAction={<Button variant="text" onClick={demo}>Deploy a sample</Button>}25 />26 )27 }2829 return <List items={items} />30}3132// Announce the change for screen-reader users33<div aria-live="polite" className="sr-only">34 {items.length === 0 ? 'No results' : items.length + ' results'}35</div>Framework-free HTML
<div class="ds-empty" role="status">
<span class="ds-empty__icon" aria-hidden="true">
<svg>…</svg>
</span>
<h3 class="ds-empty__title">No projects yet</h3>
<p class="ds-empty__desc">
A project holds your services, environments and deployments.
</p>
<div class="ds-empty__actions">
<button class="ds-btn ds-btn--filled">Create a project</button>
<button class="ds-btn ds-btn--text">Import from GitHub</button>
</div>
</div>CSS
.ds-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 64px 32px; /* the emptiness is the point */
text-align: center;
}
.ds-empty--compact { gap: 12px; padding: 40px 24px; }
.ds-empty__icon {
display: grid;
place-items: center;
inline-size: 56px;
block-size: 56px;
border-radius: var(--radius-xl);
border: 1px solid var(--ds-border-subtle);
background: var(--ds-surface-inset);
color: var(--ds-fg-muted);
}
.ds-empty__title { font-size: 1.1875rem; font-weight: 600; }
/* Centred text needs a narrower measure — every line starts somewhere new */
.ds-empty__desc {
max-inline-size: 24rem;
font-size: 13px;
line-height: 1.6;
color: var(--ds-fg-muted);
}
.ds-empty__actions { display: flex; gap: 8px; margin-block-start: 4px; }
/* Stack the actions on narrow screens rather than shrinking them */
@media (max-width: 480px) {
.ds-empty__actions { flex-direction: column; inline-size: 100%; }
.ds-empty__actions > * { inline-size: 100%; }
}Component API
EmptyState
| Prop | Type | Default | Description |
|---|---|---|---|
| title* | string | — | Rendered as a real heading. Say what is empty and why. |
| description | ReactNode | — | One or two sentences. Capped at 24rem. |
| icon | ReactNode | — | Small and decorative. Not an illustration. |
| action | ReactNode | — | The one thing to do next. Omitting it creates a dead end. |
| secondaryAction | ReactNode | — | At most one. A third turns guidance into a menu. |
| tone | 'neutral' | 'danger' | 'neutral' | danger tints the icon container for a failure state. |
| compact | boolean | false | Halves the vertical padding. For use inside a card or table. |
Professional tips
- Write the empty state before you write the list. It forces you to articulate what the feature is for, and that sentence is usually the best copy in the product.
- For a first-run state, offer a sample or a template alongside the real action. "Deploy a sample app" converts far better than a blank form.
- Keep the search term in the title but truncate it at about 40 characters, or a pasted paragraph breaks the layout.
- Inbox-zero states are one of the few places where a small piece of personality is genuinely welcome. Use it there and nowhere else.
Performance
- Do not fetch anything from an empty state. It is often rendered dozens of times as filters change, and each render should be free.
- Preload the destination of the primary action. The user is very likely to press it, and it is usually the heaviest route in the app.
- Avoid animating the empty state in. It appears after a load and after every failed filter; animation makes both feel slower.
Common mistakes
- Rendering the empty state while loading, which tells the user their data is gone.
- The same generic copy for "no data" and "no results", so the action is wrong in one of the two cases.
- No action at all, leaving the user with nowhere to go.
- Moving focus into the empty state after a search, so the user has to tab back to fix their query.
- A large illustration that pushes the action below the fold on a laptop.
Real-world recommendations
- Empty states are the highest-converting onboarding surface in most products, and usually the least designed. They are worth a dedicated review.
- Track how long new accounts sit at the first-run state. A long dwell time means the description is not explaining the concept.
- For a no-results state, log the query. A recurring failed search is either a missing feature or a vocabulary mismatch, and both are cheap to fix.
- When a filter combination can never return results, say so before the user applies it. Preventing the empty state beats designing it.