The "nothing here yet" preset over PageInfo, tuned for tables, lists, and search results: an icon, a title and description, and an optional recovery action, centered in whatever container it is given.
The canon behind it. docs/design-language/04-loading-and-states.md §A7 (04-loading-and-states.md:33, empty states are content, and they branch true/filtered/in-progress), §A9 (04-loading-and-states.md:39, an empty state's tone carries meaning, not decoration), and docs/design-language/10-overlays.md §A11 (10-overlays.md:53, empty and error states inside an overlay match the page's).
When to reach for it#
Reach for EmptyState whenever a list, table, or search result has zero rows to show. It is a thin preset over PageInfo with Icon defaulted to "NotFound", title defaulted to "Nothing here yet", and size mapped from its own compact boolean, so it never needs a bespoke empty block hand-built from <p> tags. Speedway's admin and account lists still use bare <p> empty text instead, and the design-language sweep flags this divergence explicitly rather than treating it as deliberate (docs/design-language/04-loading-and-states.md §C).
An empty list is not one state. The canon requires a real three-way branch, and shipping one generic empty state for all three fails the rule (04-loading-and-states.md §A7):
- True-empty. Nothing has ever been created here. Explain what the thing is and give a way forward. Example:
speedway/app/routes/workspaces/jobs/jobs.tsx:261-267,Icon="Table", title "No jobs yet", description "Create a job from your files. It builds its own catalog and the pipeline takes it from there," with a create action. - Filtered-empty. Something exists, but the current search or filter matches nothing. Quote the query and offer a way to clear it. Example:
speedway/app/routes/workspaces/jobs/parts.tsx:362-368, title using the live query (`No parts match "${q}"`) with a clear-search action, rendered as a sibling branch to the true-empty case in the same component right above it (:336-356). - In-progress-empty. The list is empty right now only because something is still running. Say what is happening, present tense, usually with a spinner or animated icon, not a dead end. Same
parts.tsxcomponent: whenjob.status === "ingesting", the branch swapsIcontoIngestionIconand the title to "Reading your files," specifically to avoid reading as a 404 while files are still loading. The source comment states the reasoning directly: "No Icon means EmptyState's NotFound default, which read as a 404 while files were still loading. The ingest animation says what is actually happening" (parts.tsx:339-341).
A single component owning all three branches on the same condition (parts.length === 0, split further by searching and job.status) is the reference shape. Do not ship only the true-empty or only the filtered-empty case and assume the other never happens.
Contract#
title and description accept a string or JSX; description in particular is frequently JSX with an inline action, since EmptyState also accepts a separate action slot for cases where the recovery action needs to sit apart from the description text.
Icon (default "NotFound") is one IconRender from the shared icon registry. Passing no Icon explicitly opts into the NotFound default, which reads as a 404, so an in-progress state should always set Icon explicitly (see parts.tsx above).
action is a free JSX slot for the recovery affordance: a button, a link, whatever moves the user forward. Not every empty state needs one; a "your queue is clear" state (speedway/app/routes/workspaces/workspace.tsx:388-393) has nothing to recover into and omits it.
compact tightens spacing and shrinks the icon for nested placements: inside a table body, a card, or a dense panel section. It maps to PageInfo's size="sm" versus the default "md" (empty-state.tsx:42). Real usage leans heavily compact: every nested-placement empty state in workspace.tsx and the filtered-search case in parts.tsx sets it; only the page-level true-empty states (a fresh jobs list, a fresh files list) render at the default size with extra top margin instead (className="mt-10").
iconCircle draws a soft tinted disc behind the icon; most real usage omits it and renders a bare icon, with review/log.tsx:609-615 as the one shipped exception.
gap ("sm" | "md" | "lg") overrides the vertical rhythm between rows when the compact and default spacing is not right. No shipped usage found in either app as of this sweep.
skeleton renders a placeholder block (a circle and two bars) instead of real content, inherited from PageInfo's own skeleton mode.
tone (added 2026-08-14, closing the gap plans 45 and 46 both flagged): forwards PageInfo's PageInfoTone ("neutral" | "muted" | "info" | "success" | "warning" | "error") to recolor the icon disc and icon. The canon is explicit that an empty state's tone must carry meaning, not decoration: tone="muted" for a neutral absence, tone="success" when the absence is the good outcome, such as an errors tab reporting none (04-loading-and-states.md §A9, 10-overlays.md §A11). The pre-gap shipped examples that reached for PageInfo directly to get a tone (walmart-mvp/frontend/src/pages/ErrorManagement.tsx:1087, the muted admin-list empties in Admin.tsx:237-668) become candidates to fold back onto EmptyState at that app's next kit bump.
Sanctioned combinations#
| Combination | What it produces | Where used | Why |
|---|---|---|---|
EmptyState (default size) with explicit Icon, title, description, and action, className="mt-10" | A page-level true-empty state | speedway/app/routes/workspaces/jobs/jobs.tsx:261-267 | Top margin separates it from a header above; default size reads at page weight, not nested weight. |
EmptyState compact with no action | A nested "nothing to recover, nothing to do" empty inside nested panels | speedway/app/routes/workspaces/workspace.tsx:388-393 | A queue that is empty because it is genuinely clear has no recovery affordance to offer. |
Sibling EmptyState branches on the same length === 0 condition, split by a second condition (searching, job.status) | The full three-way true, filtered, and in-progress empty split | speedway/app/routes/workspaces/jobs/parts.tsx:336-368 | This is the canon's required branch (04-loading-and-states.md §A7) implemented as one paired if/else rather than three unrelated call sites, which keeps the branches from drifting apart. |
EmptyState with a query-quoting title and a clear-search action | Filtered-empty for a live search | speedway/app/routes/workspaces/jobs/parts.tsx:363-368 | Canon preset: "quotes the query, Clear action" (04-loading-and-states.md §B). |
Banned combinations#
Do not omit Icon on a state that is empty only because something is still loading or running. The default NotFound icon reads as a dead end (a 404) rather than a transient condition; the parts.tsx comment cited above exists specifically because this was tried and read wrong.
Do not render an "all clear, this is good news" state in the default neutral. Set tone="success" for it; the canon calls the undifferentiated version out by name as a defect: "a surface reporting 'no errors' in the same grey as 'no images' tells the user two different things in one voice" (04-loading-and-states.md §A9; 10-overlays.md §A11 paraphrases the same rule).
Do not ship a single generic EmptyState covering true-empty, filtered-empty, and in-progress-empty as one undifferentiated case. This is the specific failure the three-way branch rule exists to catch: a list that is empty because nothing was ever created reads identically, under this anti-pattern, to a list that is empty because a filter matched nothing, which leaves a searching user with no way to tell "try clearing your filter" from "go create something."
Do not use walmart-mvp/frontend/src/components/EmptyState.tsx and the kit's EmptyState interchangeably. Walmart maintains its own local component of the same name (title, children, className only, no Icon, no action slot, no compact, imported from @/components/EmptyState), used throughout its pages (FileDetail.tsx, Workspace.tsx, Files.tsx, the catalog review tabs). It predates or bypasses the kit component and is not a themed variant of it; the two are not drop-in compatible, and copying a prop pattern from one page to another inside walmart-mvp does not guarantee which EmptyState is in scope.
Before you adopt this#
Five questions to answer before reaching for EmptyState.
- Does the shell, a parent layout, or a global provider already render this? Not applicable, EmptyState is page content; no shell already renders it.
- Does this app already ship a local implementation of the same thing? Walmart ships its own local
EmptyState.tsx; check the import before extending a call site. - Does this app's kit pin reach the version this component or prop landed in?
tonelanded 2026-08-14; confirm the app's kit pin reaches it. - Does the component derive its own accessible name and keyboard path, or must the call site supply them? Not applicable, the
actionslot's own button or link supplies its accessible name. - Which canon §A rules bind this surface, and which does the composition break? §A7 requires the true/filtered/in-progress split; §A9 makes tone carry meaning.
Travels with#
PageInfo is the primitive EmptyState wraps (empty-state.tsx:33-48); reach for PageInfo directly when you need a title-only failed-load state or anything else EmptyState's narrower prop set does not expose.
PageError is the sibling preset over the same PageInfo primitive for a failed load, as opposed to a genuine absence; do not use EmptyState to report an error.
IconCycle's exported animated icons (for example IngestionIcon) are shaped for the kit Icon prop contract specifically so they drop into EmptyState's Icon slot for in-progress states (speedway/app/components/IconCycle.tsx:56-59).
Snippet#
// speedway/app/routes/workspaces/jobs/parts.tsx:336-356 (true-empty and in-progress, trimmed){parts.length === 0 && !searching ? ( <EmptyState className="mt-10" // No Icon means EmptyState's NotFound default, which read as a // 404 while files were still loading. The ingest animation says // what is actually happening. Icon={job.status === "ingesting" ? IngestionIcon : "Clock"} title={job.status === "ingesting" ? "Reading your files" : "No parts"} description={ job.status === "ingesting" ? ( <span className="inline-flex items-center gap-2"> <span className="loading loading-spinner loading-sm" /> Parts appear here as each file finishes. </span> ) : ( "No usable part numbers came out of the attached files." ) } />) : ( /* ...filtered-empty branch on `searching`, parts.tsx:362-368 */)}