Empty State
alphav0.1.0The designed answer for empty surfaces — icon or illustration, title, description, capability-gated actions, and a hint, across 6 variants and 3 sizes.
Context
General-purpose empty-state for any surface that can render nothing: empty tables/lists, no-results search, failed fetches, offline banners, permission walls, first-run onboarding. The host decides when it's empty and supplies copy + handlers via props; this component only renders the answer — no data fetching, no state detection. Distinct from `DetailPanelEmptyState` (detail-panel's internal empty view) — no cross-dependency either direction, composition happens at the host (decision #35). Single-unit presentational widget: stateless, no context, capability-gated action slots (omit a handler and its button/link is absent from the DOM).
Installation
pnpm dlx shadcn@latest init"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}pnpm dlx shadcn@latest add @ilinxa/empty-stateAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/empty-state-fixturesPreview
No projects yet
No results for “gantt”
Couldn't load this page
You're offline
You don't have access
Upload your first file
Click any Primary button to log the action.
Demo source
Usage
When to use
Reach for EmptyState anywhere a surface can render nothing: an empty table/list, a no-results search, a failed fetch, an offline banner, a permission wall, or first-run onboarding. The host decides whenit's empty and supplies copy + handlers;EmptyState only renders the designed answer — icon or illustration, title, description, up to two actions, and an optional footer hint. Omit action/secondaryAction for a read-only variant — the buttons simply don't render.
Host example — search-empty inside a data table
import { EmptyState } from "@/components/empty-state";
export function ProjectsTable({ rows, filters, onClearFilters }) {
if (rows.length === 0 && filters.active) {
return (
<EmptyState
variant="search"
size="sm"
title={`No results for "${filters.query}"`}
description="Try a different term or clear the active filters."
action={{ label: "Clear filters", onClick: onClearFilters }}
/>
);
}
return <Table rows={rows} />;
}Variants
Six semantic tones, each with its own default lucide icon — override with icon, or replace the tile entirely with media (an illustration slot; when set, the icon and its halo are omitted).
default— no data yet (Inbox).search— no results for a filter/query (SearchX); pairs with a "Clear filters" action.error— something failed (TriangleAlert); pairs with a "Retry" action.offline— no connection (WifiOff).permission— locked content (Lock).first-use— onboarding tone (Sparkles), decorative spinning dashed ring around the tile.
Actions
action / secondaryAction accept either an object — { label, onClick?, href?, disabled? } — rendered as a real Button (or a plain <a> styled via buttonVariants when href is set), or any ReactNode for full control over the rendered element.
Sizes
sm for inline/card-level empties, md for a section, lg for a full-page state. Controls type scale, icon-tile scale, and vertical padding.
Frame
frame="dashed" adds a dropzone-style dashed border; frame="card" raises the root onto a bg-card surface. Default "none"renders bare (for slotting into a table body or an already-framed container). A real drag-and-drop dropzone is a separate future component — don't wire drop handlers onto frame="dashed" alone.
Accessibility
- Title renders a real heading (
headingLevel, default3) so empty states participate in the page outline. role="status" aria-live="polite"is applied only for the transient variants —search,error,offline— where content changed after a user action. Static walls (permission,first-use) stay plain landmarks; announcing them on mount would be noise.- The icon tile, bloom, and decorative ring are
aria-hidden. Actions are real, keyboard-reachable buttons/links. - Entrance animation uses
motion-safe:variants — no motion forprefers-reduced-motionusers, or whenanimated={false}.
Features
- 6 semantic variants — default / search / error / offline / permission / first-use — each with its own default lucide icon and tone
- 3 sizes (sm / md / lg) — type scale, icon-tile scale, and padding
- icon slot (overrides the variant default) or media slot (illustration; replaces the icon tile + bloom entirely)
- Capability-gated action + secondaryAction — object form renders a real Button (or a plain <a> via buttonVariants when href is set, never asChild), ReactNode form for full control; omit both and no button/link renders
- Optional footer hint slot for micro-copy (e.g. a keyboard shortcut)
- frame prop — 'none' (bare) / 'dashed' (dropzone-style border) / 'card' (raised bg-card surface)
- Semantic heading level via headingLevel (1-6, default 3) so empties participate in the page outline
- role='status' aria-live='polite' only on transient variants (search / error / offline); static walls (permission / first-use) stay silent landmarks
- 60ms-staggered entrance reveal via tw-animate-css motion-safe utilities — no app-level reveal-up dependency, so consumers get the animation without the docs-site keyframe
- animated={false} renders SSR-stable with no reveal classes; reduced-motion respected automatically
- Decorative spinning dashed ring on the first-use variant's icon tile, disabled under reduced motion and when animated={false}
- Design-token only — no hex/rgb literals, both themes