Loading, empty and error
Every data component owns its states: a skeleton shaped like the result, an empty state that teaches and an error with Retry.
The problem#
Data arrives late, arrives empty or doesn't arrive. A screen that only designs for the happy path shows a blank card, a spinner that never ends or a page held hostage by its slowest request.
Every surface that reads data spends part of its life in one of four conditions: still loading, loaded with nothing in it, failed, or loaded with content. Most mockups draw only the last one. The other three then get improvised per screen: a spinner in the middle of the page here, a bare “No data” there, a red toast for an error nobody can act on.
Cedarline runs on sample data with no backend, and it has to hold up in a live demo. That makes the three non-content states part of the product, not an afterthought: a sales engineer will slow the network on purpose to show them.
The solution in Canon#
Each component that shows data owns its loading, empty and error states, in place, at its own size. DataState draws them from one query object, so every card in the suite behaves the same.
The Owned States Rule
The Skeleton Reveal Rule
DataState (data from a query) or SkeletonReveal (any other load), which pulse it, then cross-fade and cross-blur to the content over 400ms. Never loading ? <Skeleton /> : content, an AnimatePresence fade or a FadeSwap for a load. Suspense fallbacks, static skeleton galleries, AILoader and button spinners are not reveals.Read through useMockQuery, which returns { status, data, error, retry } like a network call, and hand the result to DataState. It renders a skeleton while loading, an ErrorState with Retry on failure, an EmptyState when isEmpty says so, and your content otherwise. The skeleton pulses, then the first settled view arrives through the skeleton reveal: a 400ms ease-in-out cross-fade with a 2px cross-blur inside the same box, so nothing around it moves. Outside a query, wrap the slot in SkeletonReveal. Keys that resolved once stay warm for the session and render immediately with no animation.
const query = useMockQuery("payables.invoices.on-hold", () => onHold, { delay: 1200,});<DataState query={query} skeleton={<SkeletonRows rows={4} avatar="square" />} isEmpty={(rows) => rows.length === 0} empty={{ illustration: "done", title: "Nothing on hold", description: "Invoices that fail matching wait here for review.", action: <Button variant="outline" size="sm">Open payment run</Button>, }} errorTitle="Couldn't load invoices on hold"> {(rows) => <OnHoldList rows={rows} />}</DataState>A card that owns its states
Switch between the states, or replay the current one. Retry on the error and Clear filters on the filtered state both bring the list back.
Invoices on hold
import { Button } from "@oration/canon/components/button";import { DataState, EmptyState } from "@oration/canon/components/data-state";import { FilterChip, FilterChipRow } from "@oration/canon/components/filter-chip";import { MonogramTile } from "@oration/canon/components/monogram-tile";import { SegmentedControl } from "@oration/canon/components/segmented-control";import { SkeletonRows } from "@oration/canon/components/skeletons";import type { TagColor } from "@oration/canon/components/tag";import { toast } from "@oration/canon/components/toast";import { useMockQuery } from "@oration/canon/hooks/use-mock-query";import { Building2Icon, RotateCcwIcon } from "lucide-react";import * as React from "react";export function StateSwitcher() { type Mode = "live" | "loading" | "empty" | "filtered" | "error"; type HeldInvoice = { id: string; supplier: string; color: TagColor; reason: string; amount: string; }; const invoices: HeldInvoice[] = [ { id: "INV-20431", supplier: "Halcyon Packaging", color: "teal", reason: "Missing W-9", amount: "$4,912.50", }, { id: "INV-20409", supplier: "Halcyon Packaging", color: "teal", reason: "PO mismatch", amount: "$9,050.00", }, { id: "INV-20388", supplier: "Northwind Freight", color: "blue", reason: "Possible duplicate", amount: "$6,420.00", }, { id: "INV-20395", supplier: "Orchard Street Foods", color: "orange", reason: "Price over PO", amount: "$1,187.36", }, ]; const headingId = React.useId(); const [mode, setMode] = React.useState<Mode>("live"); const [attempt, setAttempt] = React.useState(0); const query = useMockQuery( `docs.patterns.data-states.on-hold.${mode}.${attempt}`, (): HeldInvoice[] => (mode === "empty" ? [] : invoices), { delay: 1200, fail: mode === "error", enabled: mode !== "loading" }, ); const reload = (next: Mode) => { setMode(next); setAttempt((n) => n + 1); }; return ( <div className="flex w-full max-w-xl flex-col gap-3 text-left"> <div className="flex flex-wrap items-center gap-2"> <SegmentedControl<Mode> label="Show state" value={mode} onValueChange={reload} options={[ { value: "live", label: "Live" }, { value: "loading", label: "Loading" }, { value: "empty", label: "Empty" }, { value: "filtered", label: "Filtered" }, { value: "error", label: "Error" }, ]} /> <Button variant="ghost" size="sm" className="text-muted-foreground" onClick={() => reload(mode)} > <RotateCcwIcon data-icon="inline-start" aria-hidden="true" /> Replay </Button> </div> <section aria-labelledby={headingId} className="overflow-hidden rounded-xl bg-card shadow-border" > <div className="flex items-baseline justify-between gap-3 border-b border-border px-4 py-3"> <h3 id={headingId} className="text-sm font-semibold"> Invoices on hold </h3> {query.status === "success" && mode !== "filtered" ? ( <span className="text-xs text-muted-foreground tabular-nums"> {query.data?.length ?? 0} invoices </span> ) : null} </div> <DataState query={{ ...query, retry: () => reload("live") }} skeleton={ <SkeletonRows rows={4} avatar="square" className="px-2 py-1" /> } isEmpty={(rows) => rows.length === 0} empty={{ illustration: "done", title: "Nothing on hold", description: "Invoices that fail matching wait here for review. Thursday's payment run is clear to go.", action: ( <Button variant="outline" size="sm" onClick={() => toast.add({ title: "Opening RUN-0932", description: "57 invoices, $241,908.62, Thursday Oct 1.", }) } > Open payment run </Button> ), }} errorTitle="Couldn't load invoices on hold" > {(rows) => mode === "filtered" ? ( <div className="flex flex-col"> <FilterChipRow className="border-b border-border px-3 py-2"> <FilterChip icon={ <Building2Icon aria-hidden="true" className="size-3.5 text-muted-foreground" /> } field="Supplier" valuesLabel="Pinecrest Supply" multiple={false} onRemove={() => reload("live")} /> </FilterChipRow> <EmptyState illustration="filters" title="No invoices on hold match this filter" description="Supplier is Pinecrest Supply. Clear the filter to see all 4 invoices on hold." action={ <Button variant="outline" size="sm" onClick={() => reload("live")} > Clear filters </Button> } /> </div> ) : ( <ul className="px-2 py-1"> {rows.map((row) => ( <li key={row.id} className="flex min-h-13 items-center gap-3 rounded-lg px-2 py-2 hover:bg-muted/70" > <MonogramTile name={row.supplier} color={row.color} size="lg" /> <div className="min-w-0 flex-1"> <p className="truncate text-13 font-medium"> {row.supplier} </p> <p className="flex gap-3 text-xs text-muted-foreground"> <span className="font-mono"> {row.id} </span> <span>{row.reason}</span> </p> </div> <span className="text-13 font-medium tabular-nums"> {row.amount} </span> </li> ))} </ul> ) } </DataState> </section> </div> );}Decision guide#
Which state to draw, what it contains and the words it uses.
| Situation | State | What it shows | Example copy |
|---|---|---|---|
| The read hasn't settled | Loading | A skeleton shaped like the result, from the presets. No text, no spinner. | None. Screen readers hear Loading. |
| Nothing exists yet | Empty, first use | Illustration, a title, one sentence on what the place is for, and the first action. | No suppliers yet. Suppliers are who you pay. Add supplier |
| Things exist, but filters or a search hide them all | Filtered to nothing | The filters or search illustration, the filters that hid everything, and Clear filters. | No invoices match these filters. Status is Overdue and Supplier is Halcyon Packaging. Clear filters |
| The work is done | Empty, cleared | The done illustration and what happens next. An action only if there is a next step. | Nothing on hold. Thursday's payment run is clear to go. |
| The read failed | Error | What failed, in plain words, why if known, and Retry. | Couldn't load invoices on hold. The request timed out. Try again in a moment. |
| Some rows are still resolving | Live | Content now, with per-cell shimmer for the parts still running. See Live and running states. | Finding email… |
Loading#
Show the shape of what's coming, at the size it will be, in the place it will appear.
Shape the skeleton like the result#
Rows, heights, tiles and the trailing value all line up, so the swap to content moves nothing. Toggle loading to check.
Payment runs this week
import { SkeletonReveal } from "@oration/canon/components/skeleton-reveal";import { SkeletonRows } from "@oration/canon/components/skeletons";import { Switch } from "@oration/canon/components/switch";import * as React from "react";export function SkeletonSwap() { const id = React.useId(); const [loading, setLoading] = React.useState(true); const runs = [ { id: "RUN-0930", date: "Mon, Sep 28", invoices: 38, total: "$152,377.04", }, { id: "RUN-0931", date: "Tue, Sep 29", invoices: 42, total: "$186,420.15", }, { id: "RUN-0932", date: "Thu, Oct 1", invoices: 57, total: "$241,908.62", }, ]; return ( <div className="flex w-full max-w-md flex-col gap-3 text-left"> <label htmlFor={id} className="flex items-center gap-2 text-13 text-muted-foreground" > <Switch id={id} checked={loading} onCheckedChange={setLoading} /> Loading </label> <div className="overflow-hidden rounded-xl bg-card shadow-border"> <p className="border-b border-border px-4 py-3 text-sm font-semibold"> Payment runs this week </p> <SkeletonReveal loading={loading} label="Loading payment runs" skeleton={ <SkeletonRows rows={3} avatar="square" className="px-2 py-1" /> } > <ul className="px-2 py-1"> {runs.map((run) => ( <li key={run.id} className="flex min-h-13 items-center gap-3 px-2 py-2" > <span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-xs font-medium text-muted-foreground tabular-nums"> {run.invoices} </span> <div className="min-w-0 flex-1"> <p className="truncate text-13 font-medium"> {run.date} </p> <p className="font-mono text-xs text-muted-foreground"> {run.id} </p> </div> <span className="text-13 font-medium tabular-nums"> {run.total} </span> </li> ))} </ul> </SkeletonReveal> </div> </div> );}Presets#
Seven presets from @oration/canon/components/skeletons cover most layouts. Bars share one light sweep fixed to the viewport, and the sweep stops under reduced motion. When the shape differs, compose your own from SkeletonBone.
import { SkeletonCard, SkeletonChart, SkeletonList, SkeletonRows, SkeletonStat, SkeletonTable, SkeletonText,} from "@oration/canon/components/skeletons";export function PresetGallery() { const presets = [ { name: "SkeletonRows", node: <SkeletonRows rows={3} /> }, { name: "SkeletonTable", node: <SkeletonTable rows={3} columns={4} /> }, { name: "SkeletonList", node: <SkeletonList items={4} /> }, { name: "SkeletonText", node: <SkeletonText lines={4} /> }, { name: "SkeletonChart", node: <SkeletonChart height={96} bars={10} />, }, { name: "SkeletonCard", node: <SkeletonCard lines={2} footer className="shadow-none" />, }, ]; return ( <div className="grid w-full gap-3 text-left sm:grid-cols-2 lg:grid-cols-3"> {presets.map((preset) => ( <figure key={preset.name} className="flex min-w-0 flex-col overflow-hidden rounded-xl bg-card shadow-border" > <figcaption className="border-b border-border px-4 py-2.5 font-mono text-xs text-muted-foreground"> {preset.name} </figcaption> <div className="p-3">{preset.node}</div> </figure> ))} <figure className="flex min-w-0 flex-col gap-2 sm:col-span-2 lg:col-span-3"> <figcaption className="font-mono text-xs text-muted-foreground"> SkeletonStat </figcaption> <SkeletonStat count={4} /> </figure> </div> );}Never gate a page on loading#
The header, title and every card frame render at once. Each card fills in when its own read settles: the stat strip at 500ms, the list at 1.4s, approvals at 2.2s.
Payables
Due this week
Waiting on you
import { Button } from "@oration/canon/components/button";import { DataState } from "@oration/canon/components/data-state";import { SkeletonRows, SkeletonStat } from "@oration/canon/components/skeletons";import { Stat, StatStrip } from "@oration/canon/components/stat";import { StatusLabel } from "@oration/canon/components/status-dot";import { useMockQuery } from "@oration/canon/hooks/use-mock-query";import { RotateCcwIcon } from "lucide-react";import * as React from "react";export function PageNotGated() { const [run, setRun] = React.useState(0); const stats = useMockQuery( `docs.patterns.data-states.page.stats.${run}`, () => ({ due: "$412,840.11", held: 7, matched: "92.4%" }), { delay: 500 }, ); const due = useMockQuery( `docs.patterns.data-states.page.due.${run}`, () => [ { id: "INV-20417", supplier: "Northwind Freight", amount: "$18,240.00", }, { id: "INV-20438", supplier: "Orchard Street Foods", amount: "$7,305.18", }, { id: "INV-20442", supplier: "Pinecrest Supply", amount: "$1,264.00", }, ], { delay: 1400 }, ); const approvals = useMockQuery( `docs.patterns.data-states.page.approvals.${run}`, () => [ { id: "RUN-0932", label: "Payment run for Thursday", who: "Maya Okafor", }, { id: "SUP-1188", label: "New supplier: Meridian Print Co.", who: "Priya Raman", }, ], { delay: 2200 }, ); return ( <div className="flex w-full max-w-3xl flex-col gap-4 text-left"> <div className="flex items-center justify-between gap-3"> <h3 className="text-xl font-semibold tracking-[-0.015em]"> Payables </h3> <Button variant="outline" size="sm" onClick={() => setRun((n) => n + 1)} > <RotateCcwIcon data-icon="inline-start" aria-hidden="true" /> Reload page </Button> </div> <DataState query={stats} skeleton={<SkeletonStat count={3} />}> {(data) => ( <StatStrip> <Stat label="Due this week" value={data.due} /> <Stat label="On hold" value={data.held} /> <Stat label="Matched automatically" value={data.matched} /> </StatStrip> )} </DataState> <div className="grid gap-4 md:grid-cols-2"> <section className="overflow-hidden rounded-xl bg-card shadow-border"> <h4 className="border-b border-border px-4 py-3 text-sm font-semibold"> Due this week </h4> <DataState query={due} size="sm" skeleton={ <SkeletonRows rows={3} avatar={false} className="px-2 py-1" /> } > {(rows) => ( <ul className="px-2 py-1"> {rows.map((row) => ( <li key={row.id} className="flex min-h-13 items-center gap-3 px-2 py-2" > <div className="min-w-0 flex-1"> <p className="truncate text-13 font-medium"> {row.supplier} </p> <p className="font-mono text-xs text-muted-foreground"> {row.id} </p> </div> <span className="text-13 font-medium tabular-nums"> {row.amount} </span> </li> ))} </ul> )} </DataState> </section> <section className="overflow-hidden rounded-xl bg-card shadow-border"> <h4 className="border-b border-border px-4 py-3 text-sm font-semibold"> Waiting on you </h4> <DataState query={approvals} size="sm" skeleton={ <SkeletonRows rows={2} avatar={false} className="px-2 py-1" /> } > {(rows) => ( <ul className="px-2 py-1"> {rows.map((row) => ( <li key={row.id} className="flex min-h-13 items-center gap-3 px-2 py-2" > <div className="min-w-0 flex-1"> <p className="truncate text-13 font-medium"> {row.label} </p> <p className="text-xs text-muted-foreground"> Requested by {row.who} </p> </div> <StatusLabel tone="warning" className="text-muted-foreground" > Needs approval </StatusLabel> </li> ))} </ul> )} </DataState> </section> </div> </div> );}Delays#
Mock reads wait long enough to see the craft and no longer. A few are deliberately slow so a demo can show off the skeletons.
| What | Delay | Why |
|---|---|---|
Any key without a delay | 250 to 850ms | Stable per key, from a hash of its name. |
| Ticket list | 1200ms | The Ticketing home list. |
| History conversation list | 1400ms | Agents Platform history. |
| CRM home pipeline pulse | 1500ms | The Home rail. |
| Contact Center customer context | 1600ms | The context pane. |
| Agent analytics charts | 1800ms | Charts take the longest. |
| Knowledge base AI query | 2200ms | An answer being written. |
| Spinner hold-back | 200ms | useDelayedFlag(flag, 200) so a fast action never flashes a spinner. |
| Skeleton to content | 400ms | The skeleton reveal: --reveal-dur, ease-in-out, a 2px cross-blur. The skeleton pulses every 1000ms until then. |
Hold back the spinner
For actions rather than reads, wait 200ms before showing progress. The fast answer never draws a spinner; the slow one does.
Northwind Freight
Routing 021000021
import { Button } from "@oration/canon/components/button";import { useDelayedFlag } from "@oration/canon/components/data-state";import { Spinner } from "@oration/canon/components/spinner";import * as React from "react";export function DelayedSpinner() { const [pending, setPending] = React.useState(false); const [result, setResult] = React.useState<string | null>(null); const showSpinner = useDelayedFlag(pending, 200); const verify = (ms: number) => { setPending(true); setResult(null); window.setTimeout(() => { setPending(false); setResult( ms < 200 ? `Verified in ${ms}ms. No spinner was drawn.` : `Verified in ${(ms / 1000).toFixed(1)}s. The spinner appeared after 200ms.`, ); }, ms); }; return ( <div className="flex w-full max-w-md flex-col gap-3 text-left"> <div className="flex flex-col gap-3 rounded-xl bg-card p-4 shadow-border"> <div className="flex items-center justify-between gap-3 rounded-[10px] bg-muted/70 px-3 py-2.5"> <div className="min-w-0"> <p className="text-13 font-medium">Northwind Freight</p> <p className="font-mono text-xs text-muted-foreground"> Routing 021000021 </p> </div> <p aria-live="polite" className="flex h-5 items-center gap-1.5 text-xs text-muted-foreground" > {showSpinner ? ( <> <Spinner className="size-3.5" /> Checking with the bank </> ) : null} </p> </div> <div className="flex flex-wrap gap-2"> <Button variant="outline" size="sm" disabled={pending} onClick={() => verify(120)} > Verify, fast answer </Button> <Button variant="outline" size="sm" disabled={pending} onClick={() => verify(1600)} > Verify, slow answer </Button> </div> </div> <p className="min-h-5 text-13 text-muted-foreground">{result}</p> </div> );}Empty and filtered to nothing#
An empty state teaches. It says what the place is for and how to fill it, with an Illustration, never a bare icon in a circle.
Three kinds of nothing
First use invites the first action. Filtered to nothing names what hid everything and clears it. Search with no results suggests what to try instead.
First use
No suppliers yet
Filtered to nothing
No invoices match these filters
Search with no results
No results for “Nortwind”
import { Button } from "@oration/canon/components/button";import { EmptyState } from "@oration/canon/components/data-state";import { toast } from "@oration/canon/components/toast";export function EmptyKinds() { return ( <div className="grid w-full gap-3 text-left md:grid-cols-3"> <div className="flex min-w-0 flex-col overflow-hidden rounded-xl bg-card shadow-border"> <p className="border-b border-border px-4 py-2.5 text-13 font-medium"> First use </p> <EmptyState size="sm" illustration="customers" title="No suppliers yet" description="Suppliers are who you pay. Add one with a W-9 and remit-to details, or import a CSV from your ERP." action={ <Button variant="outline" size="sm" onClick={() => toast.add({ title: "New supplier", description: "The supplier form would open here.", }) } > Add supplier </Button> } /> </div> <div className="flex min-w-0 flex-col overflow-hidden rounded-xl bg-card shadow-border"> <p className="border-b border-border px-4 py-2.5 text-13 font-medium"> Filtered to nothing </p> <EmptyState size="sm" illustration="filters" title="No invoices match these filters" description="Status is Overdue and Supplier is Halcyon Packaging." action={ <Button variant="outline" size="sm" onClick={() => toast.add({ title: "Filters cleared", description: "Showing all 248 invoices.", }) } > Clear filters </Button> } /> </div> <div className="flex min-w-0 flex-col overflow-hidden rounded-xl bg-card shadow-border"> <p className="border-b border-border px-4 py-2.5 text-13 font-medium"> Search with no results </p> <EmptyState size="sm" illustration="search" title="No results for “Nortwind”" description="Check the spelling, or search by invoice number or PO." /> </div> </div> );}| Part | Rule | Cedarline example |
|---|---|---|
| Illustration | One from the set, matched to the object: customers for suppliers, filters for filtered, search for search, done for a cleared queue. | customers |
| Title | One line, sentence case, says what's missing. No period. | No suppliers yet |
| Description | One sentence on what the place is for, or which filters hid everything. | Suppliers are who you pay. |
| Action | The next step as an outline button with a verb. Filtered states always offer Clear filters. | Add supplier |
Errors#
Say what failed in plain words, offer Retry, and never blame the person.
- The title names the thing: Couldn't load remittances, not Error or Something went wrong.
- The description says why if it's known and what to do: The request timed out. Try again in a moment. No status codes, no stack traces.
- Retry is an outline button in the error state itself. A failed read never raises a toast; the card that failed is where the person is looking.
- The error replaces only the failed component. Its neighbours keep working.
- A failure the person caused, such as an invalid field, is a field error, not an error state. See Forms and validation.
Couldn't load remittances
Error 504
Upstream request timeout. Something went wrong, please contact your administrator.
Demo controls#
The user menu's Demo controls scale every mock read and can fail each first attempt, so anyone can show these states live. This local copy drives one card.
Recent remittances
import { Button } from "@oration/canon/components/button";import { DataState } from "@oration/canon/components/data-state";import { SegmentedControl } from "@oration/canon/components/segmented-control";import { SkeletonRows } from "@oration/canon/components/skeletons";import { Switch } from "@oration/canon/components/switch";import { useMockQuery } from "@oration/canon/hooks/use-mock-query";import { RotateCcwIcon } from "lucide-react";import * as React from "react";export function DemoControls() { type Latency = "instant" | "realistic" | "slow"; const multiplier: Record<Latency, number> = { instant: 0, realistic: 1, slow: 3, }; const failId = React.useId(); const [latency, setLatency] = React.useState<Latency>("realistic"); const [failFirst, setFailFirst] = React.useState(true); const [run, setRun] = React.useState(0); const [attempt, setAttempt] = React.useState(0); const query = useMockQuery( `docs.patterns.data-states.controls.${run}.${attempt}`, () => [ { id: "REM-5512", supplier: "Northwind Freight", sent: "Sep 28, 9:14 AM", }, { id: "REM-5509", supplier: "Orchard Street Foods", sent: "Sep 28, 9:14 AM", }, { id: "REM-5501", supplier: "Halcyon Packaging", sent: "Sep 25, 4:02 PM", }, ], { delay: Math.max(1, 800 * multiplier[latency]), fail: failFirst && attempt === 0, }, ); const replay = () => { setAttempt(0); setRun((n) => n + 1); }; return ( <div className="flex w-full max-w-lg flex-col gap-3 text-left"> <div className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-[10px] bg-muted/70 px-3 py-2"> <SegmentedControl<Latency> label="Latency" value={latency} onValueChange={setLatency} options={[ { value: "instant", label: "Instant" }, { value: "realistic", label: "Realistic" }, { value: "slow", label: "Slow" }, ]} /> <label htmlFor={failId} className="flex items-center gap-2 text-13 text-muted-foreground" > <Switch id={failId} checked={failFirst} onCheckedChange={setFailFirst} /> Fail first attempt </label> <Button variant="ghost" size="sm" className="ml-auto" onClick={replay} > <RotateCcwIcon data-icon="inline-start" aria-hidden="true" /> Replay loading </Button> </div> <section className="overflow-hidden rounded-xl bg-card shadow-border"> <h3 className="border-b border-border px-4 py-3 text-sm font-semibold"> Recent remittances </h3> <DataState query={{ ...query, retry: () => setAttempt((n) => n + 1) }} skeleton={ <SkeletonRows rows={3} avatar={false} className="px-2 py-1" /> } errorTitle="Couldn't load remittances" > {(rows) => ( <ul className="px-2 py-1"> {rows.map((row) => ( <li key={row.id} className="flex min-h-13 items-center gap-3 px-2 py-2" > <div className="min-w-0 flex-1"> <p className="truncate text-13 font-medium"> {row.supplier} </p> <p className="font-mono text-xs text-muted-foreground"> {row.id} </p> </div> <span className="text-xs text-muted-foreground tabular-nums"> {row.sent} </span> </li> ))} </ul> )} </DataState> </section> </div> );}| Setting | Values | Effect |
|---|---|---|
| Latency | Instant, Realistic, Slow | Multiplies every wait by 0, 1 or 3. Instant renders content on first paint. |
| Failures | On or off | Every query fails on its first attempt, so each error state and its Retry can be shown. |
| Replay loading | An action | replayLoading() forgets every warm key, so mounted cards show their skeletons again. |
import { replayLoading, setDemoSettings, useDemoSettings,} from "@oration/canon/lib/demo-data";// What the user menu's Demo controls call.setDemoSettings({ latency: "slow", failures: true });replayLoading(); // forget warm keys so every card shows its skeleton again// Read-only, for a component that wants to show the current setting.const { latency, failures } = useDemoSettings();Settings persist per browser in localStorage under oration:demo-settings. They exist for reviewing states, so product code never branches on them.
Do and don't#
Inbox zero
Supplier emails that need a reply land here.
No data
Accessibility#
What DataState does for you and what it leaves to the screen.
- While loading, the wrapper sets
aria-busyand renders a screen-reader-only Loading. Skeleton presets arearia-hidden. - Errors are announced politely through a status region with the title and description. Empty states and arriving content are not announced, so give the card a heading that names it.
- Retry swaps the error for the skeleton, so the focused button disappears. If someone pressed Retry, move focus to the card's heading when content arrives.
- Illustrations are decorative and hidden from assistive tech; the title carries the meaning.
- The shimmer and the pulse stop under reduced motion, and the skeleton reveal swaps instantly.
- The empty-state action is a real button with a verb, reachable by Tab.
Known gaps
Only errors reach the status region today, and nothing restores focus after Retry. Both are recorded on Data state.
Components involved#
The parts this pattern is built from.
| Component | Role here |
|---|---|
| Data state | DataState picks the view; EmptyState and ErrorState draw empty, filtered and failed; useDelayedFlag holds back spinners. |
| Mock query | useMockQuery(key, load, { delay, fail, enabled }), the read every data component goes through. |
| Demo controls | Latency, failures and replayLoading() for showing states live. |
| Skeleton | The seven presets and SkeletonBone. |
| Illustration | The art for empty and error states. |
| Empty | Primitives for an empty state that doesn't fit the standard layout, such as two actions. |
| Filter chip | Shows which filter hid everything, beside Clear filters. |