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
Preview
No results found
Try adjusting your filters.
import{EmptyState,EmptyStateMedia,EmptyStateTitle,EmptyStateDescription,EmptyStateActions}from"@/components/ui/empty-state";import{Button}from"@/components/ui/button";<EmptyStateorientation="vertical"size="md"><EmptyStateMedia><SearchIcon /></EmptyStateMedia><EmptyStateTitle>No results found</EmptyStateTitle><EmptyStateDescription>Try adjusting your filters.</EmptyStateDescription><EmptyStateActions><Buttonvariant="secondary">Clear filters</Button></EmptyStateActions></EmptyState>
import{EmptyState}from"@primitiv-ui/react";import{Button}from"@primitiv-ui/react";<EmptyState.Root><EmptyState.Media><SearchIcon /></EmptyState.Media><EmptyState.Title>No results found</EmptyState.Title><EmptyState.Description>Try adjusting your filters.</EmptyState.Description><EmptyState.Actions><Buttonvariant="secondary">Clear filters</Button></EmptyState.Actions></EmptyState.Root>
Density is set by a data-density ancestor — the Context system, not a EmptyState prop.
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
Five parts, the same five in both modes — the copied file mirrors the headless compound rather than flattening it into props, which is what makes asChild on the Title possible. Only the Root's accepted props differ: orientation and size belong to the copied surface. Every part is optional except the Root; drop the Media for a text-only state, or the Actions when there is nothing to recover to. The root carries no padding of its own and centres itself in whatever box you give it, because padding is the container's job — the dashed regions in these examples are the page's, not the component's.
<EmptyState><EmptyStateMedia><SearchIcon /></EmptyStateMedia><EmptyStateTitle>No results found</EmptyStateTitle><EmptyStateDescription>Try adjusting your filters.</EmptyStateDescription><EmptyStateActions><Buttonvariant="secondary">Clear filters</Button></EmptyStateActions></EmptyState>
<EmptyState.Root><EmptyState.Media><SearchIcon /></EmptyState.Media><EmptyState.Title>No results found</EmptyState.Title><EmptyState.Description>Try adjusting your filters.</EmptyState.Description><EmptyState.Actions><Buttonvariant="secondary">Clear filters</Button></EmptyState.Actions></EmptyState.Root>
Props
Generated from source — headless rows from each *Props type’s JSDoc, styled rows from contract.json. Never hand-maintained.
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.
Prop
Type
Default
From
Description
asChild
boolean
false
headless
When 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"
vertical
styled
Layout 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"
md
styled
Empty-state size; data-density scales each size further.
EmptyState.Media
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
asChild
boolean
false
headless
When 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.
Prop
Type
Default
From
Description
asChild
boolean
false
headless
When 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.
Prop
Type
Default
From
Description
asChild
boolean
false
headless
When 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.
Prop
Type
Default
From
Description
asChild
boolean
false
headless
When 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
17 CSS custom properties on .primitiv-empty-state — mode-agnostic. These names are the stable surface; the values are not.
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
Every example below reacts to the density control. Currently showing Styled mode.
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.
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><Buttonvariant="secondary">Clear filters</Button></EmptyStateActions></EmptyState>{/* Present on load — nothing changed, so nothing to announce. */}<EmptyStaterole={undefined}><EmptyStateTitle>No messages</EmptyStateTitle></EmptyState>
import{EmptyState}from"@primitiv-ui/react";import{Button}from"@primitiv-ui/react";{/* Replaced content that vanished — announce it. */}<EmptyState.Root><EmptyState.Media><SearchIcon /></EmptyState.Media><EmptyState.Title>No results found</EmptyState.Title><EmptyState.Description>Try adjusting your filters.</EmptyState.Description><EmptyState.Actions><Buttonvariant="secondary">Clear filters</Button></EmptyState.Actions></EmptyState.Root>{/* Present on load — nothing changed, so nothing to announce. */}<EmptyState.Rootrole={undefined}><EmptyState.Title>No messages</EmptyState.Title></EmptyState.Root>
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.
No projects yet
Create one to get started.
import{EmptyState,EmptyStateTitle,EmptyStateDescription}from"@/components/ui/empty-state";<EmptyStaterole={undefined}><EmptyStateTitleasChild><h4>No projects yet</h4></EmptyStateTitle><EmptyStateDescription>Create one to get started.</EmptyStateDescription></EmptyState>
import{EmptyState}from"@primitiv-ui/react";<EmptyState.Rootrole={undefined}><EmptyState.TitleasChild><h4>No projects yet</h4></EmptyState.Title><EmptyState.Description>Create one to get started.</EmptyState.Description></EmptyState.Root>
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.
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 */}<EmptyStateorientation="vertical"><EmptyStateMedia><SearchIcon /></EmptyStateMedia><EmptyStateTitle>No results found</EmptyStateTitle><EmptyStateDescription>Try adjusting your filters.</EmptyStateDescription></EmptyState>{/* horizontal — media beside inline-start-aligned text */}<EmptyStateorientation="horizontal"><EmptyStateMedia><SearchIcon /></EmptyStateMedia><EmptyStateTitle>No results found</EmptyStateTitle><EmptyStateDescription>Try adjusting your filters.</EmptyStateDescription></EmptyState>
import{EmptyState}from"@primitiv-ui/react";{/* vertical (the default) — media above centred text */}<EmptyState.Root><EmptyState.Media><SearchIcon /></EmptyState.Media><EmptyState.Title>No results found</EmptyState.Title><EmptyState.Description>Try adjusting your filters.</EmptyState.Description></EmptyState.Root>{/* horizontal — media beside inline-start-aligned text */}<EmptyState.Root><EmptyState.Media><SearchIcon /></EmptyState.Media><EmptyState.Title>No results found</EmptyState.Title><EmptyState.Description>Try adjusting your filters.</EmptyState.Description></EmptyState.Root>
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.
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><Buttonvariant="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>
import{EmptyState}from"@primitiv-ui/react";import{Button}from"@primitiv-ui/react";{/* everything — a full illustrated region */}<EmptyState.Root><EmptyState.Media><SearchIcon /></EmptyState.Media><EmptyState.Title>No results found</EmptyState.Title><EmptyState.Description>Try adjusting your filters.</EmptyState.Description><EmptyState.Actions><Buttonvariant="secondary">Clear filters</Button></EmptyState.Actions></EmptyState.Root>{/* text only — no media, no actions */}<EmptyState.Root><EmptyState.Title>No results found</EmptyState.Title><EmptyState.Description>Try adjusting your filters.</EmptyState.Description></EmptyState.Root>
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.
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";<divdata-density="comfortable"><EmptyStatesize="xs"><EmptyStateMedia><SearchIcon /></EmptyStateMedia><EmptyStateTitle>XS</EmptyStateTitle><EmptyStateDescription>Try adjusting your filters.</EmptyStateDescription><EmptyStateActions><Buttonvariant="secondary">Clear filters</Button></EmptyStateActions></EmptyState><EmptyStatesize="sm"><EmptyStateMedia><SearchIcon /></EmptyStateMedia><EmptyStateTitle>SM</EmptyStateTitle><EmptyStateDescription>Try adjusting your filters.</EmptyStateDescription><EmptyStateActions><Buttonvariant="secondary">Clear filters</Button></EmptyStateActions></EmptyState><EmptyStatesize="md"><EmptyStateMedia><SearchIcon /></EmptyStateMedia><EmptyStateTitle>MD</EmptyStateTitle><EmptyStateDescription>Try adjusting your filters.</EmptyStateDescription><EmptyStateActions><Buttonvariant="secondary">Clear filters</Button></EmptyStateActions></EmptyState><EmptyStatesize="lg"><EmptyStateMedia><SearchIcon /></EmptyStateMedia><EmptyStateTitle>LG</EmptyStateTitle><EmptyStateDescription>Try adjusting your filters.</EmptyStateDescription><EmptyStateActions><Buttonvariant="secondary">Clear filters</Button></EmptyStateActions></EmptyState><EmptyStatesize="xl"><EmptyStateMedia><SearchIcon /></EmptyStateMedia><EmptyStateTitle>XL</EmptyStateTitle><EmptyStateDescription>Try adjusting your filters.</EmptyStateDescription><EmptyStateActions><Buttonvariant="secondary">Clear filters</Button></EmptyStateActions></EmptyState></div>
import{EmptyState}from"@primitiv-ui/react";import{Button}from"@primitiv-ui/react";<divdata-density="comfortable"><EmptyState.Root><EmptyState.Media><SearchIcon /></EmptyState.Media><EmptyState.Title>XS</EmptyState.Title><EmptyState.Description>Try adjusting your filters.</EmptyState.Description><EmptyState.Actions><Buttonvariant="secondary">Clear filters</Button></EmptyState.Actions></EmptyState.Root><EmptyState.Root><EmptyState.Media><SearchIcon /></EmptyState.Media><EmptyState.Title>SM</EmptyState.Title><EmptyState.Description>Try adjusting your filters.</EmptyState.Description><EmptyState.Actions><Buttonvariant="secondary">Clear filters</Button></EmptyState.Actions></EmptyState.Root><EmptyState.Root><EmptyState.Media><SearchIcon /></EmptyState.Media><EmptyState.Title>MD</EmptyState.Title><EmptyState.Description>Try adjusting your filters.</EmptyState.Description><EmptyState.Actions><Buttonvariant="secondary">Clear filters</Button></EmptyState.Actions></EmptyState.Root><EmptyState.Root><EmptyState.Media><SearchIcon /></EmptyState.Media><EmptyState.Title>LG</EmptyState.Title><EmptyState.Description>Try adjusting your filters.</EmptyState.Description><EmptyState.Actions><Buttonvariant="secondary">Clear filters</Button></EmptyState.Actions></EmptyState.Root><EmptyState.Root><EmptyState.Media><SearchIcon /></EmptyState.Media><EmptyState.Title>XL</EmptyState.Title><EmptyState.Description>Try adjusting your filters.</EmptyState.Description><EmptyState.Actions><Buttonvariant="secondary">Clear filters</Button></EmptyState.Actions></EmptyState.Root></div>