DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
AlertBannerToastEmptyStateTooltipCoachMark
AI agentsContributing
  1. Components
  2. EmptyState

EmptyState

Centered surface for "no data here" — empty lists, search results, tables, dashboards, inboxes, 404s, first-run / onboarding moments. Four variants, four sizes, semantic intents tinting the icon tile, primary + secondary actions, optional footer, heading control, LTR/RTL.

$ pnpm add @tessinaui/ui

When to use

  • A region with nothing to show yet. An empty list, table, inbox or dashboard, or a first-run view where the feature has not been used.
  • A search or filter with no matches. Say what was searched and offer a way back, such as clearing filters.
  • Content that was removed or is out of reach. Explain why the space is empty, and what the person can do about it.
  • A way forward, not a dead end. Give one primary action and keep secondaryAction quieter; leave both out when nothing applies.
  • A positive "all done". A cleared queue is also an empty state, and its copy can be calm rather than apologetic.

When not to use

  • While data is loading. Use Skeleton, or EmptyStateSkeleton to hold the empty state's own box.
  • For an error on a page that still shows content, or one that needs immediate action. Use Alert or Banner; an empty state is for the region that has no content (see the comparison below).
  • For the "+ New project" tile in a grid. That is a create button; variant="dashed" is the region shape, not the grid tile.
  • For a small or incidental gap. A single empty card or a one-line "none" needs plain text, or the minimal shapes in Minimal.
  • For a multi-step first-run flow. Link to your own onboarding from the action instead of stacking steps in the empty state.

Installation

Terminal
pnpm add @tessinaui/ui

Usage

example.tsx
import { EmptyState } from "@tessinaui/ui";
example.tsx
{/* Empty inbox */}
<EmptyState
  icon={<Inbox />}
  title="Inbox is empty"
  description="When new messages arrive, they'll show up here."
  action={{ label: "Compose", leadingIcon: <Plus /> }}
/>

{/* No search results — error intent */}
<EmptyState
  icon={<SearchX />}
  intent="error"
  title="No matching results"
  description="Try a different search term, or clear filters to see everything."
  action={{ label: "Clear filters", variant: "outline", intent: "none" }}
  secondaryAction={{ label: "Reset search" }}
/>

{/* Drop zone — dashed variant */}
<EmptyState
  variant="dashed"
  icon={<Upload />}
  title="Drop files here"
  description="PNG, JPG, PDF up to 10 MB. Or click to browse."
  action={{ label: "Choose files" }}
/>

{/* 404 / not found */}
<EmptyState
  size="lg"
  image={
    <div className="size-20 rounded-full bg-error-light flex items-center justify-center">
      <CloudOff className="size-10 text-error" />
    </div>
  }
  title="Page not found"
  description="The page you're looking for has been moved or no longer exists."
  action={{ label: "Go home" }}
  secondaryAction={{ label: "Contact support" }}
/>

Examples

Default

A simple empty state with an icon, title, description, and a primary action.

Variants

Four surface styles — plain, card, dashed, and soft (a borderless muted band). On soft, muted text steps to text-foreground/75 and the neutral icon tile to bg-background, so both stay AA on the tinted plate.

Sizes

Four sizes — sm, md, lg, and xl — scale padding, icon tile, and type.

Intents

intent tints the leading icon tile while title and description stay neutral.

With actions

A primary action, a quieter secondary action, and a footer for alternative paths.

Loading

EmptyStateSkeleton reserves the same vertical rhythm while content loads. It is silent by default (house skeleton policy) — pass label="Loading…" to opt into a single role="status" announcement.

Minimal

The quiet end of the range — text-only, bare icon + caption, and the compact start-aligned in-panel state with a link-weight action. All are field-dominant shapes; icon, title, description and actions are each optional.

Illustration

image replaces the icon tile with a custom illustration. The media slot caps itself at max-w-full, so a wide graphic cannot overflow a narrow column.

Start-aligned

align="start" anchors copy and actions to the leading edge. Pair with titleAs when the empty state is the sole content of a region and its title belongs in the page outline.

Error and offline

Error/not-found/offline presets ship as recipes, not exports — intent plus your copy covers everything a dedicated ErrorEmptyState component would hard-code. Three action tiers: filled retry, ghost secondary, and a footer for the error ID + support link.

On color

A plain empty state paints no panel, so inside a coloured Surface its title, description and action read the plate. card, dashed and soft paint a page panel and keep the page's look.

RTL

With align="start", dir="rtl" anchors the icon, copy and actions to the right edge, and the primary action comes first on the right. With dir unset the empty state inherits the page's direction.

EmptyState vs Banner vs Alert

EmptyStateBannerAlert
Purpose"No data here" — fills a content areaPromotional / informational — opt-in marketingReactive feedback — system event
LayoutAlways centered (or start), fills containerInline / centered / landscape, rides above contentInline, tight
ContainerPlain by default; card or dashed chromeAlways has surface chromeTight bordered/filled card
ExamplesEmpty inbox, no search results, 404, first-run"Unlock Premium", "Join our rewards programme""Failed to save", "Update available"
Default rolenone (static page content)regionderived per intent

Two boundaries: an error that needs immediate action is a Banner/Alert, not an EmptyState; and the "+ New project" dashed create-tile in a project grid is a ghost create-button, not this component — variant="dashed" is the region shape, not the grid tile.

API Reference

Props

PropTypeDefaultDescription
titleReactNode—Headline
titleAs"div" | "p" | "h2"–"h6""div"Element the title renders as. Set an h2–h6 when the empty state is the sole content of a region, so it joins the page outline (same titleAs shape as the house Alert).
descriptionReactNode—Subhead / body copy. Has size-aware max-w so long copy wraps cleanly.
variant"plain" | "card" | "dashed" | "soft""plain"Surface chrome — plain for centered content with no border, card for a bordered surface, dashed for drop zones, soft for a borderless muted band
size"sm" | "md" | "lg" | "xl""md"Scales padding, gap, icon tile size, and font sizes
intent"none" | "error" | "warning" | "success" | "info""none"Tints the leading icon tile. Title and description stay neutral. ("primary" is deprecated for one minor: it is not a semantic intent — use "none".)
rounded"none" | "sm" | "md" | "lg" | "full""lg"Corner radius of the container (card, dashed, soft), the icon tile and the default actions. full caps the container at the largest panel radius and makes the icon tile a circle.
align"center" | "start""center"Content alignment
iconReactNode—Small leading icon — wrapped in a tinted square tile
imageReactNode—Larger illustration / image. Replaces icon when both passed.
actionEmptyStateAction | ReactNode—Primary CTA
secondaryActionEmptyStateAction | ReactNode—Quieter secondary CTA — defaults to ghost
footerReactNode—Small print, alternative paths, links
dir"ltr" | "rtl"inheritedText direction
roleAriaRole—No default. Pass role="status" only for a state that swaps in dynamically — and note a polite region must be mounted empty first to announce at all (see Accessibility).
classNamestring—Additional classes on the root

EmptyStateAction

When you pass an object, the empty state renders a <Button> for you with sensible defaults. When you pass a ReactNode, that node is rendered as-is — useful for SplitButton, IconButton, custom links, etc.

FieldTypeDescription
labelReactNodeButton text
onClick(event) => voidClick handler
hrefstringWhen set, the button renders as <a href> via Button's render prop
variant"primary" | "secondary" | "ghost" | "outline"Button variant
intent"none" | "error" | "warning" | "success" | "info"Button intent
size"xs" | "sm" | "md" | "lg" | "xl"Button size
rounded"none" | "sm" | "md" | "lg" | "full"Button corner radius
loadingbooleanShow spinner
disabledbooleanDisabled state
leadingIcon / trailingIconReactNodeIcons inside the button
classNamestringExtra classes merged onto the rendered button

EmptyStateSkeleton

Pass the same size, variant, rounded and align as the empty state it stands in for, and the placeholder is the same box — icon tile, title and description line boxes, and action buttons included.

PropTypeDefaultDescription
size"sm" | "md" | "lg" | "xl""md"Match the empty state's size
variant"plain" | "card" | "dashed" | "soft""plain"Match the empty state's variant. On soft the bars take an ink that shows on the plate
rounded"none" | "sm" | "md" | "lg" | "full""lg"Match the empty state's rounded
align"center" | "start""center"Match the empty state's align
showIconbooleantrueIcon tile placeholder
linesnumber2Description lines under the title
showActionbooleantruePrimary action placeholder
showSecondaryActionbooleanfalseSecondary action placeholder
showFooterbooleanfalseFooter line placeholder
labelstring—Opt-in loading announcement. Unset, the skeleton is aria-hidden; set, it is one role="status"
dir"ltr" | "rtl"inheritedText direction

Notes

  • Action defaults: when you pass an EmptyStateAction object, its variant, intent, size, and rounded default to sensible values for the empty state's intent / size. The common case (<EmptyState action={{ label: "Create" }} />) needs no extra tuning.
  • Icon vs image: icon becomes a tinted square tile coloured by intent. image is rendered as-is — pass any element. When both are passed, image wins.
  • Description sizing: description has size-aware max-w (max-w-xs to max-w-lg) and uses text-balance in centered alignment so long copy wraps into nice symmetric lines instead of one long ragged line.
  • Variants: most empty states should be plain — they fill an existing content area that already has its own surface. Use card when the empty state needs to stand alone (e.g., as a hero on a blank dashboard). Use dashed for drop zones / "click to upload" affordances.
  • RTL — works automatically via dir="rtl". Icon tile, text alignment, and action ordering all flip.
  • Copy — write the title as a short, positive statement of what goes here ("Start by adding data assets"), not what's missing, and don't word it like a button — a title isn't tappable, and a verb phrase in heading position invites a tap that does nothing.

Accessibility

EmptyState is static page content, so it has no APG widget pattern. What it announces, and when, is a decision you make per placement.

  • No role by default. A role="status" that mounts with its content already in place announces nothing (polite regions only speak on content change), and a live region wrapping the action buttons would announce their internal churn.
  • For a state that swaps in after a fetch, pass role="status" and make sure the region exists (empty) before the content lands — otherwise it stays silent. An error that needs immediate action belongs in an Alert, not here.
  • Headings are opt-in. The title defaults to a non-heading div, like the house Alert. Set titleAs="h2"–"h6" when the empty state is the sole content of a region, so it joins the page outline.
  • Media is decorative. The icon tile is aria-hidden: it repeats what the title already says. An image you pass is rendered as-is — give a meaningful illustration its own alt, and a decorative one alt="".
  • Meaning never rides on the tint. intent colours only the icon tile; title and description stay neutral, so the words carry the state. axe color-contrast is clean on the demo and playground in both themes.
  • Actions are ordinary buttons and links — object actions render a Button (an <a> when href is set) with its 44px touch floor on phones.
  • EmptyStateSkeleton is silent unless you pass label, which turns it into a single role="status" whose visually hidden label is written 150ms after mount. There is no aria-busy on it: VoiceOver + Safari never speaks a busy status region.

Keyboard

KeyBehaviour
Tab / Shift+TabMoves through action, secondaryAction, then any links in footer — natural order, no trap
Enter / SpaceActivates the focused action (Enter only for a link)

AT matrix

ATStatus
VoiceOver + Safari (macOS)Passed 2026-09-14. Reads "No items", "Items you create will show up here.", "Refresh, button", "Need help?", "link, Read the guide" — no role of its own. Transcript: tests/qa/vo-transcripts/empty-state.md
VoiceOver + Chrome (macOS)UNTESTED
NVDA + Chrome (Windows)UNTESTED — no Windows host available
JAWSUNTESTED

Status stays beta until one VoiceOver and one NVDA pass are on record.

ToastTooltip

On this page

When to useWhen not to useInstallationUsageExamplesDefaultVariantsSizesIntentsWith actionsLoadingMinimalIllustrationStart-alignedError and offlineOn colorRTLEmptyState vs Banner vs AlertAPI ReferencePropsEmptyStateActionEmptyStateSkeletonNotesAccessibilityKeyboardAT matrix