PageInfo is the centered icon, title, subtitle, and actions block for any "the page IS the message" moment: empty tables, error pages, upload prompts, not-found states. PageLoading and PageError, from the sibling page-states module, are the two other presets built on the same primitive, alongside EmptyState (documented separately in empty-state.md). One shape, so these surfaces never grow bespoke copies.
The canon behind it. docs/design-language/04-loading-and-states.md §A1 (04-loading-and-states.md:15, skeletons for content, spinners for processes, why PageLoading is the one sanctioned full-page spinner), §A7 (04-loading-and-states.md:33, empty states are content and branch), §A9 (04-loading-and-states.md:39, tone carries meaning, not decoration), and §A11 (04-loading-and-states.md:45, a failure looks like a failure and says what failed).
When to reach for it#
Reach for a preset first: EmptyState for nothing-here-yet, PageLoading for a still-fetching page or panel, PageError for a failed load. Reach for PageInfo directly only when a preset's narrower prop set does not fit. EmptyState forwards tone since 2026-08-14 (see empty-state.md's Contract), so a toned empty region no longer needs the raw primitive; the pre-gap call sites that reached for PageInfo to get one (walmart-mvp/frontend/src/pages/ErrorManagement.tsx:1087, tone="success", "All clear"; the muted admin-list empties in walmart-mvp/frontend/src/pages/Admin.tsx:237-668) become fold-back candidates at that app's next kit bump.
The canon's three-way empty branch (doc 4 §A7: true-empty, filtered-empty, in-progress-empty) applies to any PageInfo-based empty region, not only ones built through EmptyState. Naming which of the three a given render is, and picking tone accordingly (doc 4 §A9), is the job regardless of which entry point you used.
Contract#
PageInfo#
Icon(default"Info") /iconSize/iconClassName/iconWrapClassName(IconImportProps).title/subtitle: string or JSX viarenderNode.actions: free JSX slot under the text, for whatever recovers or advances (a button, a link).size("sm" | "md" | "lg", default"md"): overall scale.smfor table/card empties,mdfor sections,lgfor full-page states. Each size fixes its own padding, icon-disc size, and type scale together (page-info.tsx:6-13), so a caller never mixes a small disc with page-weight padding.tone("neutral" | "muted" | "info" | "success" | "warning" | "error", default"neutral"): recolors the icon disc and icon. Per doc 4 §A9, tone carries meaning, not decoration:mutedwhen the absence is neutral,successwhen the absence IS the good outcome (an errors tab reporting none),neutral's own icon reads as a deliberate 30%-ink wash rather than an absent one (page-info.tsx:16-19comment). Real usage:Admin.tsx:211, 302, 336, 619, 667all usetone="muted"for ordinary empty lists;ErrorManagement.tsx:1087usestone="success"for "All clear."noIconCircle: renders the bare icon with no tinted disc behind it.skeleton: a placeholder block, a circle and two bars, in place of real content.
PageLoading#
Wraps PageInfo with Icon={LoadingIcon}, noIconCircle always on, and the spinner tinted text-primary (page-states.tsx:11-44). title defaults to "Loading…"; description, action, compact (maps to PageInfo's size="sm" vs "md"), spinnerSize, spinnerClassName, and skeleton round out the props. Canon doc 4 §A7 and §B name it "the one sanctioned full-page spinner": every other loading surface in the house style is a skeleton (doc 4 §A1), and PageLoading is the deliberate exception for a genuinely full-page or full-panel transient wait.
No shipped usage of PageLoading found in either app. Both apps handle their full-page loading state by branching on data == null and rendering a page-specific skeleton component instead, for example walmart-mvp/frontend/src/pages/Home.tsx:162-164 (if (data == null) return <HomeSkeleton />) and the whole routeSkeletons.tsx boot-frame module. This matches the house rule that content placeholders are skeletons (doc 4 §A1), so the gap is not a missed convention: PageLoading remains the documented preset for a full-page spinner, but the actual full-page cases observed are all covered by page-shaped skeletons instead, leaving PageLoading for a case (a full panel with no known shape to skeleton) that has not shipped yet.
PageError#
Wraps PageInfo with Icon="Error" by default, tone="error", and iconCircle on by default (iconCircle here is the inverse of PageInfo's noIconCircle, because "errors deserve weight," doc 4 §A7). title defaults to "Something went wrong"; description, action, compact, and skeleton follow the same shape as PageLoading.
Heavily used in walmart for a failed data load: walmart-mvp/frontend/src/pages/NotFound.tsx:11, ConnectLaunch.tsx:45, ActiveParts.tsx:177, Jobs.tsx:322, 882, UnlistedParts.tsx:244, ErrorManagement.tsx:920, SpecViewer.tsx:259, 348, Home.tsx:155, PartPreviewModal.tsx:575, 854. No shipped PageError usage found in speedway. Speedway is a Remix app whose loaders throw to a route-level ErrorBoundary (speedway/app/root.tsx:566), which likely explains the gap: a loader failure there is caught by the route boundary rather than rendered as an in-page PageError block.
Sanctioned combinations#
| Combination | Produces | Where used | Why |
|---|---|---|---|
PageInfo size="sm" tone="muted" Icon={...} title={...} className="py-3" | A nested, neutral empty region inside a card section | Admin.tsx:302, 336, 619, 667 | Doc 4 §B: "Empty and unremarkable → PageInfo tone=muted" |
PageInfo Icon="Success" tone="success" size="lg" title="All clear" subtitle="..." | A tab reporting a genuinely good absence | ErrorManagement.tsx:1087 | Doc 4 §A9's worked case verbatim: "no errors is good news here, so it reads as an achievement, not an absence" |
PageError title="Couldn't load..." description={loadError} action={<Button content="Retry" .../>} | A failed-load state with a named cause and a recovery path | Home.tsx:155-160 | The failed-load preset (doc 4 §B, "Full-page transient... errors get PageError"); note the description is the caught error's own message, not a generic string, per doc 4 §A11 |
PageError title="Couldn't load this part" description={loadError}, rendered as one branch of a modal body alongside a skeleton and the loaded content | An overlay's own failed-load state, matching the page's | PartPreviewModal.tsx:853-856 | Doc 10 §A11: "empty and error states inside an overlay match the page's" |
Banned combinations#
Do not build a bespoke centered icon/title/subtitle block by hand for an empty, loading, or error moment. PageInfo (directly or through EmptyState/PageLoading/PageError) is the one primitive for this; every real instance in both apps routes through it.
Do not pick tone="neutral" (the default) for an absence that is actually good news, and do not pick tone="success" for an ordinary, unremarkable absence. Doc 4 §A9's check is direct: name what the absence MEANS before picking the tone, and if the answer is "the user won," it is not muted.
Do not render a bare, generically-worded error string in place of PageError's description. Doc 4 §A11 ranks "an error surface that shows the transport's raw string" and "a handler that flattens a caught exception into its own generic wording" as real, ranked failure modes; prefer the server's own description of the failure, or an id the user can quote.
Before you adopt this#
Five questions to answer before reaching for PageInfo.
- Does the shell, a parent layout, or a global provider already render this? Not applicable, PageInfo is page content; no shell already renders it.
- Does this app already ship a local implementation of the same thing? A bespoke centered icon/title/subtitle block is the local-clone trap; route through
PageInfo. - Does this app's kit pin reach the version this component or prop landed in?
EmptyState'stonepassthrough landed 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,
action's own button or link supplies its accessible name. - Which canon §A rules bind this surface, and which does the composition break? §A9 makes tone carry meaning; §A11 requires a failure to say what failed.
Travels with#
EmptyState, the nothing-here-yet preset over the same primitive, documented in empty-state.md; that doc also names PageError as EmptyState's sibling for a failed load rather than a genuine absence.
IconCycle's animated icons, shaped for the same Icon slot, for in-progress states passed to PageInfo or EmptyState alike.
Card, whose own skeleton mode is the box-preserving alternative when the loading surface has a known card shape to keep, as opposed to PageLoading/PageInfo skeleton's content-shaped placeholder.
Snippet#
// walmart-mvp/frontend/src/pages/ErrorManagement.tsx:1087 (tone-carrying empty region)<PageInfo Icon="Success" tone="success" size="lg" title="All clear" subtitle="Nothing open matches these filters."/>// walmart-mvp/frontend/src/pages/Home.tsx:155-160 (failed load)<PageError title="Couldn't load the dashboard" description={loadError} action={<Button size="sm" content="Retry" onClick={() => window.location.reload()} />}/>