Empty states
Summary
An outcome-led placeholder for views with nothing in them yet: icon, title, hint, and suggested next actions, centered in the container. The pattern is outcome → next step: the title names the outcome the view will show ("No findings yet", not "No data"), the hint says how it gets populated, and the action is the step that produces it.
When to use
| Case | Use it for |
|---|---|
| First run | Nothing created yet; pair with a primary action that creates the first thing. |
| No results | A search or filter matched nothing — hint at why, offer a subtle "clear filters" action. |
| Zero row in a table instead | When the table chrome (headers, toolbar, filters) must stay visible, a short "no rows" cell keeps context. Use the empty state when the whole view is empty. |
| Not for errors | A failed load is a message or banner, not an empty state; don't dress an error as emptiness. |
Examples
The house art style
Soma's empty-state illustrations share one vocabulary: the same node-and-edge language as the Nware mark, so a product's quiet moments still look like the brand. Four ready-made pieces cover the common situations; copy them, or draw your own with the recipe below.
The recipe, matching the logomark's construction:
- Two inks only: strokes and nodes in
var(--soma-color-primary), enclosed shapes filledvar(--soma-color-primary-subtle)— never literal colors, so every piece follows light, dark and high-contrast automatically. - Strokes: 2px with
roundlinecaps; faint construction lines at 1px,opacity="0.25". - Nodes: filled circles r 2–3 at line ends and junctions; one hollow node where something is absent (the lost signal's unreached terminal).
- One focal ring per piece: a larger circle with the subtle fill and a nucleus dot — the soma cell from the logomark.
- Canvas
viewBox="0 0 96 72", a dozen-and-some elements at most,aria-hidden="true"(the title carries the information).
One piece in full — the connection-lost illustration:
<svg class="soma-empty-state-art" viewBox="0 0 96 72" fill="none" aria-hidden="true">
<line x1="10" y1="22" x2="21" y2="29" stroke="var(--soma-color-primary)" stroke-width="2" stroke-linecap="round"/>
<line x1="10" y1="46" x2="21" y2="39" stroke="var(--soma-color-primary)" stroke-width="2" stroke-linecap="round"/>
<circle cx="10" cy="22" r="2.5" fill="var(--soma-color-primary)"/>
<circle cx="10" cy="46" r="2.5" fill="var(--soma-color-primary)"/>
<circle cx="30" cy="34" r="8" fill="var(--soma-color-primary-subtle)" stroke="var(--soma-color-primary)" stroke-width="2"/>
<circle cx="30" cy="34" r="2.5" fill="var(--soma-color-primary)"/>
<line x1="40" y1="34" x2="52" y2="34" stroke="var(--soma-color-primary)" stroke-width="2" stroke-linecap="round"/>
<line x1="58" y1="41" x2="63" y2="27" stroke="var(--soma-color-primary)" stroke-width="2" stroke-linecap="round"/>
<line x1="64" y1="41" x2="69" y2="27" stroke="var(--soma-color-primary)" stroke-width="2" stroke-linecap="round"/>
<line x1="74" y1="34" x2="82" y2="34" stroke="var(--soma-color-primary)" stroke-width="1" opacity="0.25" stroke-linecap="round"/>
<circle cx="87" cy="34" r="3" fill="none" stroke="var(--soma-color-primary)" stroke-width="2"/>
</svg>
HTML
The full form — icon, title, hint, actions, in that order. Everything but the title is optional:
<div class="soma-empty-state">
<span class="soma-icon soma-icon-search soma-empty-state-icon"></span>
<h3 class="soma-empty-state-title">No findings yet</h3>
<p class="soma-empty-state-hint">Run your first scan to populate this view.</p>
<div class="soma-empty-state-actions">
<button class="soma-button soma-button-primary">Start a scan</button>
</div>
</div>
The no-results form — same markup, different register: the hint explains the narrowing, and the action that undoes it is a standard button, not a primary one:
<div class="soma-empty-state">
<span class="soma-icon soma-icon-filter soma-empty-state-icon"></span>
<h3 class="soma-empty-state-title">No sessions match</h3>
<p class="soma-empty-state-hint">Nothing in the last 24 hours matches these filters.</p>
<div class="soma-empty-state-actions">
<button class="soma-button">Clear filters</button>
</div>
</div>
The minimal form — a lone title, for small panes with no next step to offer:
<div class="soma-empty-state">
<h3 class="soma-empty-state-title">Nothing scheduled</h3>
</div>
The line-art form — a hand-authored inline SVG in the optional
.soma-empty-state-art slot, replacing the icon.
Stroke it with the primary token so it recolors with every
theme, and mark it aria-hidden; it is decoration:
<div class="soma-empty-state">
<svg class="soma-empty-state-art" viewBox="0 0 96 72" fill="none" aria-hidden="true">
<rect x="20" y="42" width="56" height="20" rx="4" fill="var(--soma-color-primary-subtle)" stroke="var(--soma-color-primary)" stroke-width="2"/>
<circle cx="34" cy="20" r="2.5" fill="var(--soma-color-primary)"/>
<circle cx="48" cy="13" r="3" fill="var(--soma-color-primary)"/>
<circle cx="62" cy="20" r="2.5" fill="var(--soma-color-primary)"/>
<line x1="34" y1="24" x2="45" y2="38" stroke="var(--soma-color-primary)" stroke-width="1" opacity="0.25" stroke-linecap="round"/>
<line x1="48" y1="17" x2="48" y2="38" stroke="var(--soma-color-primary)" stroke-width="1" opacity="0.25" stroke-linecap="round"/>
<line x1="62" y1="24" x2="51" y2="38" stroke="var(--soma-color-primary)" stroke-width="1" opacity="0.25" stroke-linecap="round"/>
</svg>
<h3 class="soma-empty-state-title">Inbox zero</h3>
<p class="soma-empty-state-hint">Approvals land here when a change needs a human decision.</p>
</div>
Pick a heading level that fits the page outline: inside a
widget with an h3 title, h3 is right;
elsewhere match the surrounding hierarchy. The icon span is
decorative (subtle-tinted, no label); the title carries the
information.
If you bring your own illustrations, keep them in this line-art
language so empty states read as one family: stroke-only
geometry (2px, rounded caps and joins), fill="none",
a single accent color taken from
var(--soma-color-primary), never baked-in hex
colors or filled artwork, which would ignore the
themes. Simple enough to hand-author
in a viewBox of a few dozen units, like the tray
above. Use the art or the icon, not both.
CSS classes
| Class | Effect |
|---|---|
.soma-empty-state | Centered column with generous padding; fills its container. |
.soma-empty-state-icon | A 32px soma-icon, subtle-tinted (decorative). |
.soma-empty-state-art | Optional line-art illustration slot (inline SVG) replacing the icon: 96px wide (capped at the container), spaced like the icon. The CSS only sizes it; color comes from the SVG consuming tokens. |
.soma-empty-state-title | The outcome headline. |
.soma-empty-state-hint | Muted supporting line, width-capped (380px) for readability. |
.soma-empty-state-actions | Row of next-step buttons. |
The outcome → next-step pattern
Every slot answers one reader question. The title: what will be here? Name the outcome ("No findings yet", "No projects yet"), never the mechanism ("No data", "Empty result set"). The hint: how does it get here? One sentence on what populates the view, or for no-results, why nothing matched. The actions: what do I do now? The step that produces the first item, or undoes the narrowing. If you can't fill an action slot honestly (the view populates on its own, or elsewhere), leave it out — a button that leads away from the answer is worse than none.
Button weight follows intent: creation gets
-primary (first run is an invitation), recovery
gets the standard button ("Clear filters" is a retreat, not a
goal), and a second option ("Browse templates") stays standard
next to the primary. And the boundary holds in both directions:
emptiness is not an error, and an error is not emptiness: a
failed load keeps its message even
if the region behind it is blank.
JavaScript
None — empty states are CSS-only. The consumer's view logic decides which branch renders: content, or the empty state (and which empty state: first-run and no-results are different answers). One render function keeps the swap-in and swap-out in a single place:
const view = document.querySelector('#projects-view');
function render(projects, filtered) {
if (projects.length) {
view.innerHTML = renderProjectList(projects); // content branch
return;
}
// Empty branch — first-run vs no-results are different stories.
view.innerHTML = filtered ? emptyNoResultsHTML : emptyFirstRunHTML;
// The action buttons only exist in this branch — wire them after the swap.
view.querySelector('[data-action="create"]')
?.addEventListener('click', openCreateDialog);
view.querySelector('[data-action="clear-filters"]')
?.addEventListener('click', clearFilters);
}
Swap out is the same function from the other side; the first created item replaces the empty state wholesale:
async function onCreated() {
const projects = await fetchProjects();
render(projects, false); // one item in: the empty state is gone
}
While the first load is still in flight, show a skeleton, not an empty state: "No projects yet" must be a verdict about data, never a guess made before it arrived.