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

CaseUse it for
First runNothing created yet; pair with a primary action that creates the first thing.
No resultsA search or filter matched nothing — hint at why, offer a subtle "clear filters" action.
Zero row in a table insteadWhen 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 errorsA failed load is a message or banner, not an empty state; don't dress an error as emptiness.

Examples

Full form

Icon · title · hint · actions:

No findings yet

Run your first scan to populate this view.

First run: a primary action creates the first thing the view will show.

No results

A subtle recovery action, not a primary one:

No sessions match

Nothing in the last 24 hours matches these filters. Widen the time range or clear them.

The hint explains why it's empty; the action undoes the narrowing.

Line-art illustration

An inline SVG in the optional .soma-empty-state-art slot, instead of the icon:

Inbox zero

Approvals land here when an agent proposes a change that needs a human decision.

The art is decorative (aria-hidden) and stroke-only, colored by var(--soma-color-primary), so it recolors with every theme. Use the art or the icon, not both.

Minimal

Title only:

Nothing scheduled

Icon, hint and actions are all optional — a lone title suits small panes where there is no next step to offer.

Standalone

No widget chrome; the whole view is empty:

No projects yet

Projects you create or join appear here.

In-widget (the examples above), the state fills the widget body. When the entire view is empty, mount it directly in the content area instead. The dashed outline here stands in for that bare region. It centers itself in whatever container it gets.

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 four house pieces

No results · empty collection · connection lost · first run, all recoloring with the active theme because every stroke and fill consumes a token:

The recipe, matching the logomark's construction:

  • Two inks only: strokes and nodes in var(--soma-color-primary), enclosed shapes filled var(--soma-color-primary-subtle) — never literal colors, so every piece follows light, dark and high-contrast automatically.
  • Strokes: 2px with round linecaps; 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

ClassEffect
.soma-empty-stateCentered column with generous padding; fills its container.
.soma-empty-state-iconA 32px soma-icon, subtle-tinted (decorative).
.soma-empty-state-artOptional 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-titleThe outcome headline.
.soma-empty-state-hintMuted supporting line, width-capped (380px) for readability.
.soma-empty-state-actionsRow 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.