EmptyState
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.
Preview
empty-state · base base · style base-lyraOpen under the product themeInstallation
pnpm dlx shadcn@latest add @opsinjs/empty-state
The @opsinjs namespace is declared in your components.json. Everything it installs is code you then own. There is no runtime package to keep in step.
Usage
import { EmptyState } from "@/components/ui/empty-state"<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
Use it when
- Nothing added yet, where the fastest help is the first step.
- A filter or search that matched nothing, where the reader must know it was the filter, not the data.
- A view that cannot yet draw honestly, such as a trend with too few readings.
Do not use it when
- The content is loading and will arrive shortly.Use
skeletoninstead. - The whole view failed, rather than one surface that could not load.Use
alert-bannerinstead. - There is content, but it needs a caveat.Use
calloutinstead. - Permission is needed before anything can be shown.Use
consent-sheetinstead. - One value is missing inside a full surface.Use
valueinstead.
Anatomy
EmptyStateThe root, coloured by neither axis: an absence is not a level or a category.Controlled byreasonEmptyState.IllustrationOptional. aria-hidden, inert, hidden in print and below a 17rem content area.Controlled byillustrationEmptyState.ErrorFlagcould-not-load only: "Could not load" beside an aria-hidden icon, in neutral ink.Controlled byreasonEmptyState.TitleWhat is not here, as a real h2 to h6. A blank title says so.Controlled bytitleEmptyState.BodyWhy, as a paragraph. With no children it admits the app does not say why.Controlled bychildrenEmptyState.ActionThe one next step: a Link for an href, a neutral Button for an onSelect.Controlled byactionEmptyState.ErrorNoRetrycould-not-load with no usable action: admits no way back was offered.Controlled byactionEmptyState.SecondaryA quieter alternative, same two forms. Never a second primary, never alone.Controlled bysecondary
Examples
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.
empty-state-a-filter-matched-nothing · base base · style base-lyraOpen under the product themeWhat 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.
empty-state-no-copy-supplied · base base · style base-lyraOpen under the product themeAn 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.
empty-state-with-an-illustration · base base · style base-lyraOpen under the product themeContent 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.
Do
"No readings yet. Add your first and it will be here. [Add a reading]" Names the absence, one action.
Don’t
"Nothing to see here!" Cheerful about missing health data, no explanation, nothing to do.
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.
| Key | Action | Notes |
|---|---|---|
| Tab | Moves focus to the action, then to the quieter alternative | Nothing else is focusable. No action means no tab stop. |
| Shift+Tab | Moves focus back through the same controls | Reverse order matches visual order. |
| Enter | Activates whichever control has focus | href follows the destination, onSelect calls the handler. |
| Space | Activates the onSelect form only | The platform's button and link distinction. No page scroll. |
| After activation | The caller's, not this component's | An onSelect that unmounts the state drops focus to body. Move it. |
| Pair | Theme | APCA Lc | WCAG 2.2 | Floor |
|---|---|---|---|---|
| body text on the page | light | 101.6 | 17.18:1 | Pass |
| body text on the page | dark | -100.5 | 18.00:1 | Pass |
| body text on a card | light | 104.7 | 17.96:1 | Pass |
| body text on a card | dark | -99.6 | 16.32:1 | Pass |
| body text on the muted ground | light | 98.1 | 16.32:1 | Pass |
| body text on the muted ground | dark | -97.5 | 13.77:1 | Pass |
| secondary text on the page | light | 83.9 | 7.42:1 | Pass |
| secondary text on the page | dark | -80.7 | 13.44:1 | Pass |
| secondary text on a card | light | 87.0 | 7.76:1 | Pass |
| secondary text on a card | dark | -79.8 | 12.19:1 | Pass |
| secondary text on the muted ground | light | 80.4 | 7.05:1 | Pass |
| secondary text on the muted ground | dark | -77.8 | 10.28:1 | Pass |
| a hairline boundary on the page | light | 19.3 | 1.41:1 | Below floor |
| a hairline boundary on the page | dark | -8.2 | 1.90:1 | Below floor |
| a hairline boundary on a card | light | 22.4 | 1.47:1 | Below floor |
| a hairline boundary on a card | dark | -7.3 | 1.72:1 | Below floor |
| a hairline boundary on the muted ground | light | 15.8 | 1.34:1 | Below floor |
| a hairline boundary on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on the page | light | 69.8 | 4.52:1 | Pass |
| a placeholder boundary on the page | dark | -51.0 | 7.62:1 | Pass |
| a placeholder boundary on a card | light | 72.8 | 4.72:1 | Pass |
| a placeholder boundary on a card | dark | -50.1 | 6.91:1 | Pass |
| a placeholder boundary on the muted ground | light | 66.2 | 4.29:1 | Pass |
| a placeholder boundary on the muted ground | dark | -48.1 | 5.83:1 | Pass |
| a placeholder fill on the page | light | 8.3 | 1.18:1 | Below floor |
| a placeholder fill on the page | dark | -8.2 | 1.90:1 | Below floor |
| a placeholder fill on a card | light | 11.4 | 1.23:1 | Below floor |
| a placeholder fill on a card | dark | -7.3 | 1.72:1 | Below floor |
| a placeholder fill on the muted ground | light | 0.0 | 1.12:1 | Below floor |
| a placeholder fill on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on its own fill | light | 58.9 | 3.84:1 | Pass |
| a placeholder boundary on its own fill | dark | -41.5 | 4.01:1 | Below floor |
| the card hairline on the page | light | 69.8 | 4.52:1 | Pass |
| the card hairline on the page | dark | -28.7 | 4.19:1 | Below floor |
| the card hairline on a card | light | 72.8 | 4.72:1 | Pass |
| the card hairline on a card | dark | -27.9 | 3.80:1 | Below floor |
These are the measured token pairs this component draws colour from, not a measurement of the component itself.
API reference
Prop
Type
Generated from EmptyStateProps in registry/bases/base/empty-state.tsx.
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
- Skeleton is for content that is coming, not content that is absent.
- AlertBanner speaks for the whole view.
could-not-loadreports one surface's failure in place. - Callout is a note beside content that exists.
- TrendSparkline draws the too-short refusal in a chart
card.
not-enoughcarries it with no chart.
Callout
A short piece of set-apart information that helps you understand what you are reading without claiming anything about your health. A note, a tip or a caveat.
Skeleton
A grey placeholder in the shape of the content that is loading, so the page does not jump when it arrives. It is never in the shape of a number.