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 initpnpm dlx shadcn@latest add @ilinxa/empty-stateAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/empty-state-fixturesCLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:
"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}Preview
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
"use client"; import { useState } from "react";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { EmptyState } from "./empty-state";import { EMPTY_STATE_DUMMY_COPY, EMPTY_STATE_DUMMY_ILLUSTRATION } from "./dummy-data";import type { EmptyStateVariant } from "./types"; const ALL_VARIANTS: EmptyStateVariant[] = [ "default", "search", "error", "offline", "permission", "first-use",]; export default function EmptyStateDemo() { const [lastClicked, setLastClicked] = useState<string | null>(null); return ( <Tabs defaultValue="variants" className="w-full"> <SwipeTabsList> <TabsTrigger value="variants">Variants</TabsTrigger> <TabsTrigger value="sizes">Sizes</TabsTrigger> <TabsTrigger value="read-only">Read-only</TabsTrigger> <TabsTrigger value="illustration">Illustration</TabsTrigger> <TabsTrigger value="frames">Frames</TabsTrigger> </SwipeTabsList> {/* All 6 variants at md size — each variant's default icon + tone + copy. */} <TabsContent value="variants" className="mt-6"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> {ALL_VARIANTS.map((variant) => { const copy = EMPTY_STATE_DUMMY_COPY[variant]; return ( <EmptyState key={variant} variant={variant} size="md" frame="dashed" title={copy.title} description={copy.description} action={{ label: "Primary", onClick: () => setLastClicked(`${variant} → Primary`), }} /> ); })} </div> <p className="mt-4 text-xs text-muted-foreground"> {lastClicked ? ( <> Last action: <code>{lastClicked}</code> </> ) : ( "Click any Primary button to log the action." )} </p> </TabsContent> {/* sm / md / lg on the same variant, to compare type scale + icon scale + padding. */} <TabsContent value="sizes" className="mt-6"> <div className="flex flex-col divide-y divide-border rounded-lg border border-border"> <EmptyState size="sm" variant="search" title={EMPTY_STATE_DUMMY_COPY.search.title} description={EMPTY_STATE_DUMMY_COPY.search.description} action={{ label: "Clear filters" }} /> <EmptyState size="md" variant="search" title={EMPTY_STATE_DUMMY_COPY.search.title} description={EMPTY_STATE_DUMMY_COPY.search.description} action={{ label: "Clear filters" }} /> <EmptyState size="lg" variant="search" title={EMPTY_STATE_DUMMY_COPY.search.title} description={EMPTY_STATE_DUMMY_COPY.search.description} action={{ label: "Clear filters" }} /> </div> </TabsContent> {/* No action/secondaryAction handlers at all — I2 proof: zero <button>/<a> in this tab's DOM. */} <TabsContent value="read-only" className="mt-6 max-w-md mx-auto"> <EmptyState size="md" variant="permission" title={EMPTY_STATE_DUMMY_COPY.permission.title} description={EMPTY_STATE_DUMMY_COPY.permission.description} hint="Contact your workspace admin for access." /> <p className="mt-4 text-center text-xs text-muted-foreground"> No <code>action</code> / <code>secondaryAction</code> passed — no buttons render (capability-gating, I2). </p> </TabsContent> {/* media slot (illustration wins over icon) + first-use's spinning dashed ring. */} <TabsContent value="illustration" className="mt-6"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <EmptyState size="md" variant="default" media={EMPTY_STATE_DUMMY_ILLUSTRATION} title="No charts yet" description="Illustrations replace the icon tile entirely (I3)." action={{ label: "Create chart" }} /> <EmptyState size="md" variant="first-use" title={EMPTY_STATE_DUMMY_COPY["first-use"].title} description={EMPTY_STATE_DUMMY_COPY["first-use"].description} action={{ label: "Browse files" }} secondaryAction={{ label: "Learn more" }} /> </div> </TabsContent> {/* frame="dashed" (dropzone-style) vs frame="card" (raised bg-card). */} <TabsContent value="frames" className="mt-6"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <EmptyState size="md" frame="dashed" variant="first-use" title="Drop files here" description="Or browse from your device." action={{ label: "Browse" }} /> <EmptyState size="md" frame="card" variant="default" title="No integrations connected" description="Connect a service to start syncing data." action={{ label: "Connect" }} secondaryAction={{ label: "View docs", href: "#" }} /> </div> </TabsContent> </Tabs> );} 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