opsinjs
ComponentsFeedback

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

Theme
Density
Text
Status
empty-state · base base · style base-lyraOpen under the product theme

Installation

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 skeleton instead.
  • The whole view failed, rather than one surface that could not load.Use alert-banner instead.
  • There is content, but it needs a caveat.Use callout instead.
  • Permission is needed before anything can be shown.Use consent-sheet instead.
  • One value is missing inside a full surface.Use value instead.

Anatomy

  1. EmptyStateThe root, coloured by neither axis: an absence is not a level or a category.Controlled by reason
  2. EmptyState.IllustrationOptional. aria-hidden, inert, hidden in print and below a 17rem content area.Controlled by illustration
  3. EmptyState.ErrorFlagcould-not-load only: "Could not load" beside an aria-hidden icon, in neutral ink.Controlled by reason
  4. EmptyState.TitleWhat is not here, as a real h2 to h6. A blank title says so.Controlled by title
  5. EmptyState.BodyWhy, as a paragraph. With no children it admits the app does not say why.Controlled by children
  6. EmptyState.ActionThe one next step: a Link for an href, a neutral Button for an onSelect.Controlled by action
  7. EmptyState.ErrorNoRetrycould-not-load with no usable action: admits no way back was offered.Controlled by action
  8. EmptyState.SecondaryA quieter alternative, same two forms. Never a second primary, never alone.Controlled by secondary

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.

Theme
Density
Text
Status
empty-state-a-filter-matched-nothing · base base · style base-lyraOpen under the product theme

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.

Theme
Density
Text
Status
empty-state-no-copy-supplied · base base · style base-lyraOpen under the product theme

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.

Theme
Density
Text
Status
empty-state-with-an-illustration · base base · style base-lyraOpen under the product theme

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.

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.

KeyActionNotes
TabMoves focus to the action, then to the quieter alternativeNothing else is focusable. No action means no tab stop.
Shift+TabMoves focus back through the same controlsReverse order matches visual order.
EnterActivates whichever control has focushref follows the destination, onSelect calls the handler.
SpaceActivates the onSelect form onlyThe platform's button and link distinction. No page scroll.
After activationThe caller's, not this component'sAn onSelect that unmounts the state drops focus to body. Move it.
PairThemeAPCA LcWCAG 2.2Floor
body text on the pagelight101.617.18:1Pass
body text on the pagedark-100.518.00:1Pass
body text on a cardlight104.717.96:1Pass
body text on a carddark-99.616.32:1Pass
body text on the muted groundlight98.116.32:1Pass
body text on the muted grounddark-97.513.77:1Pass
secondary text on the pagelight83.97.42:1Pass
secondary text on the pagedark-80.713.44:1Pass
secondary text on a cardlight87.07.76:1Pass
secondary text on a carddark-79.812.19:1Pass
secondary text on the muted groundlight80.47.05:1Pass
secondary text on the muted grounddark-77.810.28:1Pass
a hairline boundary on the pagelight19.31.41:1Below floor
a hairline boundary on the pagedark-8.21.90:1Below floor
a hairline boundary on a cardlight22.41.47:1Below floor
a hairline boundary on a carddark-7.31.72:1Below floor
a hairline boundary on the muted groundlight15.81.34:1Below floor
a hairline boundary on the muted grounddark0.01.45:1Below floor
a placeholder boundary on the pagelight69.84.52:1Pass
a placeholder boundary on the pagedark-51.07.62:1Pass
a placeholder boundary on a cardlight72.84.72:1Pass
a placeholder boundary on a carddark-50.16.91:1Pass
a placeholder boundary on the muted groundlight66.24.29:1Pass
a placeholder boundary on the muted grounddark-48.15.83:1Pass
a placeholder fill on the pagelight8.31.18:1Below floor
a placeholder fill on the pagedark-8.21.90:1Below floor
a placeholder fill on a cardlight11.41.23:1Below floor
a placeholder fill on a carddark-7.31.72:1Below floor
a placeholder fill on the muted groundlight0.01.12:1Below floor
a placeholder fill on the muted grounddark0.01.45:1Below floor
a placeholder boundary on its own filllight58.93.84:1Pass
a placeholder boundary on its own filldark-41.54.01:1Below floor
the card hairline on the pagelight69.84.52:1Pass
the card hairline on the pagedark-28.74.19:1Below floor
the card hairline on a cardlight72.84.72:1Pass
the card hairline on a carddark-27.93.80:1Below 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.

  • Skeleton is for content that is coming, not content that is absent.
  • AlertBanner speaks for the whole view. could-not-load reports 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-enough carries it with no chart.

On this page