Skip to content

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

Every component that shows data owns its loading, empty and error states, with a skeleton shaped like its final layout. A whole page is never gated on loading.

The Skeleton Reveal Rule

Every skeleton that stands in for loading content reaches the screen through 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.

The contract
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

Loading
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.

Which state, when
SituationStateWhat it showsExample copy
The read hasn't settledLoadingA skeleton shaped like the result, from the presets. No text, no spinner.None. Screen readers hear Loading.
Nothing exists yetEmpty, first useIllustration, 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 allFiltered to nothingThe 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 doneEmpty, clearedThe 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 failedErrorWhat 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 resolvingLiveContent 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

Loading payment runs
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.

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

Loading

Due this week

Loading

Waiting on you

Loading
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.

Delays
WhatDelayWhy
Any key without a delay250 to 850msStable per key, from a hash of its name.
Ticket list1200msThe Ticketing home list.
History conversation list1400msAgents Platform history.
CRM home pipeline pulse1500msThe Home rail.
Contact Center customer context1600msThe context pane.
Agent analytics charts1800msCharts take the longest.
Knowledge base AI query2200msAn answer being written.
Spinner hold-back200msuseDelayedFlag(flag, 200) so a fast action never flashes a spinner.
Skeleton to content400msThe 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

Suppliers are who you pay. Add one with a W-9 and remit-to details, or import a CSV from your ERP.

Filtered to nothing

No invoices match these filters

Status is Overdue and Supplier is Halcyon Packaging.

Search with no results

No results for “Nortwind”

Check the spelling, or search by invoice number or PO.
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>    );}
Writing an empty state
PartRuleCedarline example
IllustrationOne from the set, matched to the object: customers for suppliers, filters for filtered, search for search, done for a cleared queue.customers
TitleOne line, sentence case, says what's missing. No period.No suppliers yet
DescriptionOne sentence on what the place is for, or which filters hid everything.Suppliers are who you pay.
ActionThe 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

The request timed out. Try again in a moment.
Do. Name what didn't load and offer Retry in place.

Error 504

Upstream request timeout. Something went wrong, please contact your administrator.

Don't. Show a status code and a vague apology with no way forward.

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

Loading
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>    );}
Demo settings
SettingValuesEffect
LatencyInstant, Realistic, SlowMultiplies every wait by 0, 1 or 3. Instant renders content on first paint.
FailuresOn or offEvery query fails on its first attempt, so each error state and its Retry can be shown.
Replay loadingAn actionreplayLoading() forgets every warm key, so mounted cards show their skeletons again.
The real controls
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#

Do. Draw a skeleton in the card's own frame, shaped like its rows.
Don't. Center a spinner in an empty card. It says nothing about what's coming, and the content jumps in when it lands.

Inbox zero

Supplier emails that need a reply land here.

Do. Teach with an Illustration, a title, one sentence and the next action.

No data

Don't. Put a lucide icon in a gray circle above No data. It's the generic dashboard empty state Canon refuses.
Do. When filters hide everything, name them and offer Clear filters.
Don't. Reuse the first-use empty state. It tells someone with 248 invoices that they have none.
Do. Let each card settle on its own while the page stays usable.
Don't. Hold the whole page behind one loader until every request has finished.

Accessibility#

What DataState does for you and what it leaves to the screen.

  • While loading, the wrapper sets aria-busy and renders a screen-reader-only Loading. Skeleton presets are aria-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.

Components involved
ComponentRole here
Data stateDataState picks the view; EmptyState and ErrorState draw empty, filtered and failed; useDelayedFlag holds back spinners.
Mock queryuseMockQuery(key, load, { delay, fail, enabled }), the read every data component goes through.
Demo controlsLatency, failures and replayLoading() for showing states live.
SkeletonThe seven presets and SkeletonBone.
IllustrationThe art for empty and error states.
EmptyPrimitives for an empty state that doesn't fit the standard layout, such as two actions.
Filter chipShows which filter hid everything, beside Clear filters.