---
title: "EmptyState"
description: "What a screen shows before there is anything on it. It says plainly why it is empty and gives you one clear thing to do about it."
url: "https://opsinjs.pensievelabs.org/components/empty-state"
source: "https://opsinjs.pensievelabs.org/components/empty-state.md"
section: "Components"
status: "shipped"
kind: "component"
category: "feedback"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["blank slate", "no data", "nothing here", "zero state"]
usedIn: ["choose-a-component", "consent-flow", "daily-log-screen", "daily-logging", "diabetes-medicines-app", "empty-and-first-use", "onboarding-and-first-run", "onboarding-screen", "results-screen", "trend-review", "trends-screen"]
implemented: true
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> `<StubNotice>` IS THE EXCEPTION, AND IT IS THE ONE TO READ. It is a
> paired element rather than a self-closing one, and the text between
> its opening and closing tags is prose an author wrote, reproduced
> below word for word. That prose is where this page says whether the
> component has been reviewed. Read the children, not only the
> attributes.

<StubNotice
  name="empty-state"
  status="shipped"
  questions="[
  &#x22;Whether an empty state may carry a primary action the reader cannot yet take, such as during a device sync.&#x22;,
  &#x22;Whether an illustration ever helps beside an absence of somebody's own health data has not been researched here.&#x22;,
  &#x22;A view that becomes empty should be announced once, but this component mounts no live region and nothing says what the region does.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="empty-state" />

## Installation [#installation]

<ComponentInstall name="empty-state" unbuilt="false" importPath="@/components/ui/empty-state" registryDependencies="[&#x22;button&#x22;, &#x22;link&#x22;]" />

## Usage [#usage]

```tsx
import { EmptyState } from "@/components/ui/empty-state"
```

```tsx
<EmptyState
  reason="nothing-yet"
  title="No readings yet"
  action={{ label: "Add a reading", href: "/readings/new" }}
>
  Once you add your first reading it will be here, with the range your product
  compares it against.
</EmptyState>
```

## When to use it [#when-to-use-it]

<WhenToUse
  use="[
  &#x22;Nothing added yet, where the fastest help is the first step.&#x22;,
  &#x22;A filter or search that matched nothing, where the reader must know it was the filter, not the data.&#x22;,
  &#x22;A view that cannot yet draw honestly, such as a trend with too few readings.&#x22;,
]"
  avoid="[
  { case: &#x22;The content is loading and will arrive shortly.&#x22;, instead: &#x22;skeleton&#x22; },
  { case: &#x22;The whole view failed, rather than one surface that could not load.&#x22;, instead: &#x22;alert-banner&#x22; },
  { case: &#x22;There is content, but it needs a caveat.&#x22;, instead: &#x22;callout&#x22; },
  { case: &#x22;Permission is needed before anything can be shown.&#x22;, instead: &#x22;consent-sheet&#x22; },
  { case: &#x22;One value is missing inside a full surface.&#x22;, instead: &#x22;value&#x22; },
]"
/>

## Anatomy [#anatomy]

<Anatomy
  name="empty-state"
  parts="[
  {
    name: &#x22;EmptyState&#x22;,
    describes: &#x22;The root, coloured by neither axis: an absence is not a level or a category.&#x22;,
    prop: &#x22;reason&#x22;,
  },
  {
    name: &#x22;EmptyState.Illustration&#x22;,
    describes: &#x22;Optional. aria-hidden, inert, hidden in print and below a 17rem content area.&#x22;,
    prop: &#x22;illustration&#x22;,
  },
  {
    name: &#x22;EmptyState.ErrorFlag&#x22;,
    describes: &#x22;could-not-load only: \&#x22;Could not load\&#x22; beside an aria-hidden icon, in neutral ink.&#x22;,
    prop: &#x22;reason&#x22;,
  },
  {
    name: &#x22;EmptyState.Title&#x22;,
    describes: &#x22;What is not here, as a real h2 to h6. A blank title says so.&#x22;,
    prop: &#x22;title&#x22;,
  },
  {
    name: &#x22;EmptyState.Body&#x22;,
    describes: &#x22;Why, as a paragraph. With no children it admits the app does not say why.&#x22;,
    prop: &#x22;children&#x22;,
  },
  {
    name: &#x22;EmptyState.Action&#x22;,
    describes: &#x22;The one next step: a Link for an href, a neutral Button for an onSelect.&#x22;,
    prop: &#x22;action&#x22;,
  },
  {
    name: &#x22;EmptyState.ErrorNoRetry&#x22;,
    describes: &#x22;could-not-load with no usable action: admits no way back was offered.&#x22;,
    prop: &#x22;action&#x22;,
  },
  {
    name: &#x22;EmptyState.Secondary&#x22;,
    describes: &#x22;A quieter alternative, same two forms. Never a second primary, never alone.&#x22;,
    prop: &#x22;secondary&#x22;,
  },
]"
/>

## Examples [#examples]

### A filter matched nothing [#a-filter-matched-nothing]

`reason="no-matches"` with an `onSelect` that clears the filter. The list owns the
live region and moves focus when the control unmounts.

<ComponentPreview name="empty-state-a-filter-matched-nothing" kind="example" align="start" />

### What happens when nobody wrote the body [#what-happens-when-nobody-wrote-the-body]

The first card has no `children`, so it admits the app does not say why and
warns. The second has two sentences.

<ComponentPreview name="empty-state-no-copy-supplied" kind="example" align="start" />

### An illustration, and what it is not allowed to do [#an-illustration-and-what-it-is-not-allowed-to-do]

`illustration` is `aria-hidden` and `inert`, hidden in print and below 17rem.
Cover it and the state still reads whole.

<ComponentPreview name="empty-state-with-an-illustration" kind="example" align="start" />

## Content guidelines [#content-guidelines]

Say what is not here, then why, then what to do. For `not-enough`, state the
rule and the gap. For `could-not-load`, say the load failed, not that the reading is
missing. See
[Error and empty messages](../content/error-and-empty-messages.mdx).

<DoDont>
  <DoDont.Do>
    **"No readings yet. Add your first and it will be here. \[Add a reading]"**
    Names the absence, one action.
  </DoDont.Do>

  <DoDont.Dont>
    **"Nothing to see here!"** Cheerful about missing health data, no
    explanation, nothing to do.
  </DoDont.Dont>
</DoDont>

## Accessibility [#accessibility]

Audited against WCAG 2.2 AA in a source pass and a rendered pass. The audit is
author-run, not an independent review, and clinical review is pending.

Both passes found nothing to change. Neither colour axis is used, so nothing can
mix. `titleLevel` renders a real `h2` to `h6`, default `2`; check it against the outline. The `could-not-load` flag carries its meaning in a word beside an
`aria-hidden` icon, not colour. The `href` action is an anchor floored at
`var(--opsin-target-minimum, 2.75rem)` on both axes.

No live region is mounted, by design. On a stateless, read-only render a live region
could only mis-fire, since it announces only changes made after first paint. The
announcement belongs to whatever region changed, which the caller owns, as the
filter example shows.

The rendered pass measured the default preview on `/view` and returned clean; the
three example files were audited from source. Contrast against the card surface is
unmeasured; the report below carries it.

<KeyboardTable
  name="empty-state"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Moves focus to the action, then to the quieter alternative&#x22;,
    notes: &#x22;Nothing else is focusable. No action means no tab stop.&#x22;,
  },
  {
    keys: &#x22;Shift + Tab&#x22;,
    action: &#x22;Moves focus back through the same controls&#x22;,
    notes: &#x22;Reverse order matches visual order.&#x22;,
  },
  {
    keys: &#x22;Enter&#x22;,
    action: &#x22;Activates whichever control has focus&#x22;,
    notes: &#x22;href follows the destination, onSelect calls the handler.&#x22;,
  },
  {
    keys: &#x22;Space&#x22;,
    action: &#x22;Activates the onSelect form only&#x22;,
    notes: &#x22;The platform's button and link distinction. No page scroll.&#x22;,
  },
  {
    keys: &#x22;After activation&#x22;,
    action: &#x22;The caller's, not this component's&#x22;,
    notes: &#x22;An onSelect that unmounts the state drops focus to body. Move it.&#x22;,
  },
]"
/>

<ContrastReport component="empty-state" />

## API reference [#api-reference]

<PropsTable name="EmptyStateProps" />

`reason` and `title` are required. There is no exported `EmptyReason` type, so
name the union `EmptyStateProps["reason"]`. A blank `title`, a body with no
text or only a number, and an action with nothing to do each render a fallback and
warn once. A `secondary` with no `action` also warns.

## Related [#related]

* [Skeleton](./skeleton.mdx) is for content that is coming, not content that is
  absent.
* [AlertBanner](./alert-banner.mdx) speaks for the whole view. `could-not-load`
  reports one surface's failure in place.
* [Callout](./callout.mdx) is a note beside content that exists.
* [TrendSparkline](./trend-sparkline.mdx) draws the too-short refusal in a chart
  card. `not-enough` carries it with no chart.
