Skip to content
Primitiv home
Framework
Consumption mode

Empty State

stableSource Figma

The placeholder shown when a collection, search, or view has no content — a decorative media slot, a headline, supporting copy, and recovery actions, centred in the space it fills. Composes the headless EmptyState primitive (role="status", a polite live region). Sized xs–xl; data-density scales each size further.

Playground

Density

Preview

No results found

Try adjusting your filters.

Size
Orientation
import { EmptyState, EmptyStateMedia, EmptyStateTitle, EmptyStateDescription, EmptyStateActions } from "@/components/ui/empty-state";import { Button } from "@/components/ui/button";
<EmptyState orientation="vertical" size="md">  <EmptyStateMedia><SearchIcon /></EmptyStateMedia>  <EmptyStateTitle>No results found</EmptyStateTitle>  <EmptyStateDescription>Try adjusting your filters.</EmptyStateDescription>  <EmptyStateActions>    <Button variant="secondary">Clear filters</Button>  </EmptyStateActions></EmptyState>

Density is set by a data-density ancestor — the Context system, not a EmptyState prop.

Installation

npx primitiv add empty-state

Import

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

Copied into your project as .primitiv-empty-state — you own the file afterwards, so upgrades are opt-in.

Headless mode installs the npm package instead: @primitiv-ui/react

Anatomy

<EmptyState>  <EmptyStateMedia><SearchIcon /></EmptyStateMedia>  <EmptyStateTitle>No results found</EmptyStateTitle>  <EmptyStateDescription>Try adjusting your filters.</EmptyStateDescription>  <EmptyStateActions>    <Button variant="secondary">Clear filters</Button>  </EmptyStateActions></EmptyState>

Props

EmptyState.Root

Styled surface only — the copied file adds this region. @primitiv-ui/react exports no such part, so under Headless it is your own element.

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessWhen true, renders the consumer's own element instead of the sub-component's default host element, merging all props (aria-, data-, event handlers) onto it via the Slot utility. The child must be a single React element. For EmptyState.Root and EmptyState.Media, the implicit role="status" / aria-hidden="true" are also forwarded through Slot.
orientation"vertical" | "horizontal"verticalstyledLayout axis. vertical stacks the media above centred text — the canonical empty state. horizontal places the media beside inline-start-aligned text, for a compact/inline empty region.
size"xs" | "sm" | "md" | "lg" | "xl"mdstyledEmpty-state size; data-density scales each size further.

EmptyState.Media

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessWhen true, renders the consumer's own element instead of the sub-component's default host element, merging all props (aria-, data-, event handlers) onto it via the Slot utility. The child must be a single React element. For EmptyState.Root and EmptyState.Media, the implicit role="status" / aria-hidden="true" are also forwarded through Slot.

EmptyState.Title

Extends HTMLParagraphElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessWhen true, renders the consumer's own element instead of the sub-component's default host element, merging all props (aria-, data-, event handlers) onto it via the Slot utility. The child must be a single React element. For EmptyState.Root and EmptyState.Media, the implicit role="status" / aria-hidden="true" are also forwarded through Slot.

EmptyState.Description

Extends HTMLParagraphElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessWhen true, renders the consumer's own element instead of the sub-component's default host element, merging all props (aria-, data-, event handlers) onto it via the Slot utility. The child must be a single React element. For EmptyState.Root and EmptyState.Media, the implicit role="status" / aria-hidden="true" are also forwarded through Slot.

EmptyState.Actions

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessWhen true, renders the consumer's own element instead of the sub-component's default host element, merging all props (aria-, data-, event handlers) onto it via the Slot utility. The child must be a single React element. For EmptyState.Root and EmptyState.Media, the implicit role="status" / aria-hidden="true" are also forwarded through Slot.

Styling contract

--primitiv-empty-state-gap--primitiv-empty-state-text-gap--primitiv-empty-state-actions-gap--primitiv-empty-state-max-inline-size--primitiv-empty-state-media-size--primitiv-empty-state-media-offset--primitiv-empty-state-media-color--primitiv-empty-state-title-font-family--primitiv-empty-state-title-font-size--primitiv-empty-state-title-font-weight--primitiv-empty-state-title-line-height--primitiv-empty-state-title-color--primitiv-empty-state-description-font-family--primitiv-empty-state-description-font-size--primitiv-empty-state-description-font-weight--primitiv-empty-state-description-line-height--primitiv-empty-state-description-color

Accessibility

  • The Root is role="status" — a polite live region, announced when the user is idle rather than interrupting. That suits an empty state that replaces content which just disappeared. Compare Alert, which is assertive and interrupts: if the message is urgent, that is the component, not this one.
  • Opt out with role={undefined} when the state was there on load. There is no change to announce, and a live region rendered already-populated is unreliably announced anyway — so the role buys nothing and risks a stray announcement. This is the one prop most empty states should be passing and almost none do.
  • EmptyState.Title is a <p>, not a heading, so it adds nothing to the document outline. Deliberate inside a live region; wrong when the empty state is the page's main content. Promote it with asChild in that case, and drop the role at the same time. Choose the level from the outline it lands in, not from the component: a heading that skips a level is as much of a problem for heading navigation as no heading at all.
  • EmptyState.Media is decorative. Whatever you put inside should be aria-hidden (or an <svg> with no accessible name) — the message is the Title and Description, and an icon that announces "magnifying glass" before them just delays it.
  • Actions are ordinary focusable controls in document order, so they are reached by Tab like anything else. Put the primary recovery first: it is both the reading order and the tab order, and a screen-reader user hears the announcement then tabs straight into it.
  • Say what happened and what to do. "No results found" plus "Try adjusting your filters" is two sentences doing two jobs; a bare "Nothing here" leaves a user who cannot see the surrounding UI with no idea whether something failed or they simply have no data yet.
  • The component has no padding of its own and centres in the box it is given, so an empty state in a region with no height collapses to nothing. That is a layout bug rather than an accessibility one, but it presents as a live region announcing content nobody can see.

Examples

It announces itself (the headline)

The Root is role="status" — a polite live region, so when it replaces content that has just vanished (a search that returned nothing, a filter that excluded everything) a screen reader announces it once the user is idle rather than interrupting. That is the case it is built for. It is the wrong behaviour when the empty state was on the page at load: there is no change to announce, and a live region that arrives already-populated is unreliable anyway. Pass role={undefined} to opt out — the primitive's own JSDoc documents this, and it is why role is a plain forwarded attribute rather than something the component locks down.

Density

No results found

Try adjusting your filters.

No messages

import { EmptyState, EmptyStateMedia, EmptyStateTitle, EmptyStateDescription, EmptyStateActions } from "@/components/ui/empty-state";import { Button } from "@/components/ui/button";
{/* Replaced content that vanished — announce it. */}<EmptyState>  <EmptyStateMedia><SearchIcon /></EmptyStateMedia>  <EmptyStateTitle>No results found</EmptyStateTitle>  <EmptyStateDescription>Try adjusting your filters.</EmptyStateDescription>  <EmptyStateActions>    <Button variant="secondary">Clear filters</Button>  </EmptyStateActions></EmptyState>
{/* Present on load — nothing changed, so nothing to announce. */}<EmptyState role={undefined}>  <EmptyStateTitle>No messages</EmptyStateTitle></EmptyState>

Promoting the title to a heading

EmptyState.Title renders a <p>. That is right for a live region — the whole state is announced as a status, and a heading inside one adds an outline entry for something transient — but wrong when the empty state IS the page's main content, where it should be a real heading so heading navigation reaches it. asChild swaps the element and keeps the styling, which is the single most useful thing it does here, and the reason this is a compound of parts rather than a title prop: a prop could set the text but never the tag. Pick the LEVEL from the surrounding outline rather than reaching for <h1> — the demo below uses <h4> because it sits under this page's <h3>; an empty state filling a whole route would be <h1> or <h2>. Pair it with role={undefined} — if the state is the page's main content, it was there on load and has nothing to announce.

Density

No projects yet

Create one to get started.

import { EmptyState, EmptyStateTitle, EmptyStateDescription } from "@/components/ui/empty-state";
<EmptyState role={undefined}>  <EmptyStateTitle asChild>    <h4>No projects yet</h4>  </EmptyStateTitle>  <EmptyStateDescription>Create one to get started.</EmptyStateDescription></EmptyState>

Orientation

vertical stacks the media above centred text — the canonical full-region empty state. horizontal puts the media beside inline-start-aligned text, which is the shape that fits an inline or compact region: a narrow panel, a card, a table's empty body. Note that the text alignment changes with it, not just the axis; a horizontal state with centred text reads as a mistake, so the two travel together. Both are shown here because the difference is the point.

Density

vertical

No results found

Try adjusting your filters.

horizontal

No results found

Try adjusting your filters.

import { EmptyState, EmptyStateMedia, EmptyStateTitle, EmptyStateDescription } from "@/components/ui/empty-state";
{/* vertical (the default) — media above centred text */}<EmptyState orientation="vertical">  <EmptyStateMedia><SearchIcon /></EmptyStateMedia>  <EmptyStateTitle>No results found</EmptyStateTitle>  <EmptyStateDescription>Try adjusting your filters.</EmptyStateDescription></EmptyState>
{/* horizontal — media beside inline-start-aligned text */}<EmptyState orientation="horizontal">  <EmptyStateMedia><SearchIcon /></EmptyStateMedia>  <EmptyStateTitle>No results found</EmptyStateTitle>  <EmptyStateDescription>Try adjusting your filters.</EmptyStateDescription></EmptyState>

Only the Root is required

Every part below the Root is optional, so the same component covers a full illustrated region and a single line of text. Two judgement calls worth making deliberately. Drop the Media when the region is too short to give an icon room — a glyph squeezed into 40px of height reads as a bug, not as art. And drop the Actions when there is genuinely nothing to recover to: an empty state whose only button reloads the page is worse than one that simply says what happened.

Density

everything

No results found

Try adjusting your filters.

title and description only

No results found

Try adjusting your filters.

import { EmptyState, EmptyStateMedia, EmptyStateTitle, EmptyStateDescription, EmptyStateActions } from "@/components/ui/empty-state";import { Button } from "@/components/ui/button";
{/* everything — a full illustrated region */}<EmptyState>  <EmptyStateMedia><SearchIcon /></EmptyStateMedia>  <EmptyStateTitle>No results found</EmptyStateTitle>  <EmptyStateDescription>Try adjusting your filters.</EmptyStateDescription>  <EmptyStateActions>    <Button variant="secondary">Clear filters</Button>  </EmptyStateActions></EmptyState>
{/* text only — no media, no actions */}<EmptyState>  <EmptyStateTitle>No results found</EmptyStateTitle>  <EmptyStateDescription>Try adjusting your filters.</EmptyStateDescription></EmptyState>

Sizes and density

Five sizes, each rescaling again with the nearest data-density ancestor. Size moves the media, both type ramps and the gaps together. Pick it from the region the state fills rather than from the message's importance — an xl empty state inside a small card is the most common way this component goes wrong.

Density

XS

Try adjusting your filters.

SM

Try adjusting your filters.

MD

Try adjusting your filters.

LG

Try adjusting your filters.

XL

Try adjusting your filters.

import { EmptyState, EmptyStateMedia, EmptyStateTitle, EmptyStateDescription, EmptyStateActions } from "@/components/ui/empty-state";import { Button } from "@/components/ui/button";
<div data-density="comfortable">  <EmptyState size="xs">    <EmptyStateMedia><SearchIcon /></EmptyStateMedia>    <EmptyStateTitle>XS</EmptyStateTitle>    <EmptyStateDescription>Try adjusting your filters.</EmptyStateDescription>    <EmptyStateActions>      <Button variant="secondary">Clear filters</Button>    </EmptyStateActions>  </EmptyState>  <EmptyState size="sm">    <EmptyStateMedia><SearchIcon /></EmptyStateMedia>    <EmptyStateTitle>SM</EmptyStateTitle>    <EmptyStateDescription>Try adjusting your filters.</EmptyStateDescription>    <EmptyStateActions>      <Button variant="secondary">Clear filters</Button>    </EmptyStateActions>  </EmptyState>  <EmptyState size="md">    <EmptyStateMedia><SearchIcon /></EmptyStateMedia>    <EmptyStateTitle>MD</EmptyStateTitle>    <EmptyStateDescription>Try adjusting your filters.</EmptyStateDescription>    <EmptyStateActions>      <Button variant="secondary">Clear filters</Button>    </EmptyStateActions>  </EmptyState>  <EmptyState size="lg">    <EmptyStateMedia><SearchIcon /></EmptyStateMedia>    <EmptyStateTitle>LG</EmptyStateTitle>    <EmptyStateDescription>Try adjusting your filters.</EmptyStateDescription>    <EmptyStateActions>      <Button variant="secondary">Clear filters</Button>    </EmptyStateActions>  </EmptyState>  <EmptyState size="xl">    <EmptyStateMedia><SearchIcon /></EmptyStateMedia>    <EmptyStateTitle>XL</EmptyStateTitle>    <EmptyStateDescription>Try adjusting your filters.</EmptyStateDescription>    <EmptyStateActions>      <Button variant="secondary">Clear filters</Button>    </EmptyStateActions>  </EmptyState></div>