Skip to content

Empty

The primitives for an empty state: illustration, title, one sentence and the next action.

Status
Stable
Category
Feedback
Adoption
Not used yet
import { Empty } from "@oration/canon/components/empty";
packages/canon/src/components/empty.tsx
No payment runs scheduled
A payment run pays approved invoices in one batch, on the day you pick. Most teams run one every Friday.
import { Button } from "@oration/canon/components/button";import {  Empty,  EmptyContent,  EmptyDescription,  EmptyHeader,  EmptyMedia,  EmptyTitle,} from "@oration/canon/components/empty";import { Illustration } from "@oration/canon/components/illustration";import { toast } from "@oration/canon/components/toast";export function Hero() {    return (        <Empty className="max-w-lg rounded-xl bg-card py-10 shadow-border">            <EmptyHeader>                <EmptyMedia>                    <Illustration name="schedule" />                </EmptyMedia>                <EmptyTitle>No payment runs scheduled</EmptyTitle>                <EmptyDescription>                    A payment run pays approved invoices in one batch, on the                    day you pick. Most teams run one every Friday.                </EmptyDescription>            </EmptyHeader>            <EmptyContent className="flex-row justify-center">                <Button                    type="button"                    onClick={() =>                        toast.add({                            title: "New payment run",                            description:                                "Pick a date and the invoices to include.",                        })                    }                >                    Schedule a run                </Button>                <Button                    type="button"                    variant="ghost"                    onClick={() =>                        toast.add({                            type: "info",                            title: "Opened How payment runs work",                            description: "A 3-minute read.",                        })                    }                >                    How payment runs work                </Button>            </EmptyContent>        </Empty>    );}

Usage#

Empty is the set of primitives for an empty state: a media slot, a one-line title, one sentence about what the place is for, and the next action. Most empty states in Oration don't need the primitives at all; EmptyState from Data state takes an illustration name, a title, a description and an action and draws the standard layout, and DataState renders it for you when a query comes back empty. Reach for Empty when that layout doesn't fit, such as two actions or a page-level empty. Either way the media is an Illustration, never a lucide icon in a gray tile.

When to use

  • When a list, board or panel has nothing in it yet and the reader needs to know what it's for and how to fill it.
  • For filtered-to-nothing states that name the filters and offer Clear filters.
  • For search with no matches that repeats the query and suggests what to try.
  • For a finished queue: You're all caught up on approvals or an inbox.
  • When EmptyState can't express the layout: two actions, a link beside a button, custom media or a full-page empty.

When not to use

  • For a standard empty state with one action. Use the ready-made EmptyState, or DataState's empty prop. Use Data state
  • For a failed load. That is an error state with Retry, not an empty one. Use Data state
  • While data is loading. Show a skeleton shaped like the result. Use Skeleton
  • For an empty row inside a settings table. Use Card table
  • To explain a page that already has content. Use a dismissible intro. Use Page intro

The Owned States Rule

Every component that shows data owns its loading, empty and error states, with a skeleton shaped like its final layout. The empty state lives inside the component, not on a separate page.

Illustrations, not icon circles

Empty states use an Illustration, a title, one sentence and the next action. A bare lucide icon in a circle or tile is one of the patterns Canon refuses.

Anatomy#

No transferred calls
Calls an agent hands off land here.
  1. Container. Empty: a centered column with 16px gaps and 24px padding. Give it the surface it sits on, such as rounded-xl bg-card shadow-border, or none inside a card.
  2. Media. EmptyMedia holding an Illustration: 120px wide by default, 80px (size="sm") in rails and small cards.
  3. Title. EmptyTitle, 14px at weight 500. What is empty, in a few words: No payment runs scheduled.
  4. Description. EmptyDescription, 14px Slate Meta, up to 24rem wide. One sentence about what the place is for, or what hid everything.
  5. Content. EmptyContent: the next action, usually one outline button. A filled button only when filling this place is what the view exists for.

Examples#

The ready-made EmptyState

Start here. EmptyState draws the illustration, title, sentence and action from props, at md for panels and sm for rails. DataState renders the same thing from its empty prop.

No conversations waiting

Calls and chats the agents can't finish land here for your team.

No scorecards yet

Scorecards grade calls against your checklist.
import { Button } from "@oration/canon/components/button";import { EmptyState } from "@oration/canon/components/data-state";import { toast } from "@oration/canon/components/toast";export function ReadyMade() {    return (        <div className="flex w-full flex-wrap items-start justify-center gap-4">            <div className="max-w-sm flex-1 rounded-xl bg-card shadow-border">                <EmptyState                    illustration="inbox"                    title="No conversations waiting"                    description="Calls and chats the agents can't finish land here for your team."                    action={                        <Button                            type="button"                            variant="outline"                            size="sm"                            onClick={() =>                                toast.add({ title: "Opened routing rules" })                            }                        >                            Review routing                        </Button>                    }                />            </div>            <div className="w-64 flex-none rounded-xl bg-card shadow-border">                <EmptyState                    size="sm"                    illustration="scorecards"                    title="No scorecards yet"                    description="Scorecards grade calls against your checklist."                />            </div>        </div>    );}

Filtered to nothing

Name the filters that hid everything and offer Clear filters. Remove a chip or clear them all and the list comes back in place.

StatusisOnboardingCountryisCanada
No suppliers match these filters
Onboarding and Canada hide all 48 suppliers. Remove a filter to see more.
import { Button } from "@oration/canon/components/button";import {  Empty,  EmptyContent,  EmptyDescription,  EmptyHeader,  EmptyMedia,  EmptyTitle,} from "@oration/canon/components/empty";import { FilterChip, FilterChipRow } from "@oration/canon/components/filter-chip";import { Illustration } from "@oration/canon/components/illustration";import { CircleDotIcon, GlobeIcon } from "lucide-react";import * as React from "react";export function FilteredToNothing() {    const [filters, setFilters] = React.useState([        { field: "Status", value: "Onboarding", icon: CircleDotIcon },        { field: "Country", value: "Canada", icon: GlobeIcon },    ]);    const suppliers = [        "Northwind Freight",        "Halcyon",        "Orchard Street",        "Bluefin Packaging",    ];    return (        <div className="flex w-full max-w-lg flex-col gap-3">            <FilterChipRow>                {filters.map((filter) => (                    <FilterChip                        key={filter.field}                        icon={                            <filter.icon                                aria-hidden="true"                                className="size-3.5 text-muted-foreground"                            />                        }                        field={filter.field}                        valuesLabel={filter.value}                        multiple={false}                        onRemove={() =>                            setFilters((current) =>                                current.filter(                                    (item) => item.field !== filter.field,                                ),                            )                        }                    />                ))}            </FilterChipRow>            {filters.length > 0 ? (                <Empty className="rounded-xl bg-card py-8 shadow-border">                    <EmptyHeader>                        <EmptyMedia>                            <Illustration name="filters" size="sm" />                        </EmptyMedia>                        <EmptyTitle>                            No suppliers match these filters                        </EmptyTitle>                        <EmptyDescription>                            {filters                                .map((filter) => filter.value)                                .join(" and ")}{" "}                            hide all 48 suppliers. Remove a filter to see more.                        </EmptyDescription>                    </EmptyHeader>                    <EmptyContent>                        <Button                            type="button"                            variant="outline"                            size="sm"                            onClick={() => setFilters([])}                        >                            Clear filters                        </Button>                    </EmptyContent>                </Empty>            ) : (                <ul className="flex flex-col rounded-xl bg-card px-2 py-2 shadow-border">                    {suppliers.map((supplier) => (                        <li                            key={supplier}                            className="rounded-lg px-2 py-2 text-13"                        >                            {supplier}                        </li>                    ))}                    <li className="px-2 pt-1 pb-1.5 text-xs text-muted-foreground tabular-nums">                        and 44 more suppliers                    </li>                </ul>            )}        </div>    );}

No search results

Repeat the query, suggest another way to find it, and offer Clear search. Fix the spelling and the results come back in place.

No suppliers match "Northwind Frieght"
Check the spelling, or search by remittance ID or tax ID.
import { Button } from "@oration/canon/components/button";import {  Empty,  EmptyContent,  EmptyDescription,  EmptyHeader,  EmptyMedia,  EmptyTitle,} from "@oration/canon/components/empty";import { Illustration } from "@oration/canon/components/illustration";import { Input } from "@oration/canon/components/input";import * as React from "react";export function NoResults() {    const id = React.useId();    const [query, setQuery] = React.useState("Northwind Frieght");    const suppliers = [        "Northwind Freight",        "Halcyon",        "Orchard Street",        "Bluefin Packaging",    ];    const matches = suppliers.filter((supplier) =>        supplier.toLowerCase().includes(query.trim().toLowerCase()),    );    return (        <div className="flex w-full max-w-lg flex-col gap-3">            <label htmlFor={id} className="sr-only">                Search suppliers            </label>            <Input                id={id}                type="search"                value={query}                placeholder="Search suppliers"                onChange={(event) => setQuery(event.target.value)}            />            {matches.length === 0 ? (                <Empty className="rounded-xl bg-card py-8 shadow-border">                    <EmptyHeader>                        <EmptyMedia>                            <Illustration name="search" size="sm" />                        </EmptyMedia>                        <EmptyTitle>                            No suppliers match "{query.trim()}"                        </EmptyTitle>                        <EmptyDescription>                            Check the spelling, or search by remittance ID or                            tax ID.                        </EmptyDescription>                    </EmptyHeader>                    <EmptyContent>                        <Button                            type="button"                            variant="outline"                            size="sm"                            onClick={() => setQuery("")}                        >                            Clear search                        </Button>                    </EmptyContent>                </Empty>            ) : (                <ul className="flex flex-col rounded-xl bg-card px-2 py-2 shadow-border">                    {matches.map((supplier) => (                        <li                            key={supplier}                            className="rounded-lg px-2 py-2 text-13"                        >                            {supplier}                        </li>                    ))}                </ul>            )}        </div>    );}

All caught up

A queue that was cleared gets the done illustration and a sentence about what shows up next. There is nothing to do, so there is no action.

You're all caught up
When an agent needs a decision, the proposal shows up here with its reasoning.
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@oration/canon/components/empty";import { Illustration } from "@oration/canon/components/illustration";export function AllDone() {    return (        <Empty className="max-w-lg rounded-xl bg-card py-10 shadow-border">            <EmptyHeader>                <EmptyMedia>                    <Illustration name="done" />                </EmptyMedia>                <EmptyTitle>You're all caught up</EmptyTitle>                <EmptyDescription>                    When an agent needs a decision, the proposal shows up here                    with its reasoning.                </EmptyDescription>            </EmptyHeader>        </Empty>    );}

In a rail and in a panel

In a 16rem rail, step down to the small illustration, a 13px title, a 12px sentence and an extra-small button. Panels and lists use the defaults.

No calls today
Supplier calls answered by agents show here.
No open tickets
When a supplier's question needs follow-up, it becomes a ticket here, with its SLA.
import { Button } from "@oration/canon/components/button";import {  Empty,  EmptyContent,  EmptyDescription,  EmptyHeader,  EmptyMedia,  EmptyTitle,} from "@oration/canon/components/empty";import { Illustration } from "@oration/canon/components/illustration";import { toast } from "@oration/canon/components/toast";export function Sizes() {    return (        <div className="flex w-full flex-wrap items-start justify-center gap-4">            <Empty className="w-64 flex-none gap-3 rounded-xl bg-card p-4 shadow-border">                <EmptyHeader className="gap-1">                    <EmptyMedia className="mb-1">                        <Illustration name="calls" size="sm" />                    </EmptyMedia>                    <EmptyTitle className="text-[13px]">                        No calls today                    </EmptyTitle>                    <EmptyDescription className="text-xs">                        Supplier calls answered by agents show here.                    </EmptyDescription>                </EmptyHeader>                <EmptyContent>                    <Button                        type="button"                        variant="outline"                        size="xs"                        onClick={() =>                            toast.add({ title: "Opened call history" })                        }                    >                        Open call history                    </Button>                </EmptyContent>            </Empty>            <Empty className="max-w-sm flex-1 rounded-xl bg-card py-10 shadow-border">                <EmptyHeader>                    <EmptyMedia>                        <Illustration name="tickets" />                    </EmptyMedia>                    <EmptyTitle>No open tickets</EmptyTitle>                    <EmptyDescription>                        When a supplier's question needs follow-up, it becomes a                        ticket here, with its SLA.                    </EmptyDescription>                </EmptyHeader>                <EmptyContent>                    <Button                        type="button"                        variant="outline"                        size="sm"                        onClick={() =>                            toast.add({                                title: "New ticket",                                description: "Pick the supplier it's about.",                            })                        }                    >                        Create ticket                    </Button>                </EmptyContent>            </Empty>        </div>    );}

States#

States
StateTreatment
First useNothing has been created yet. Say what the place is for, give one example if it helps, and offer the first action.
Filtered to nothingFilters hide every record. Name the filters and offer Clear filters; the filters illustration.
No search resultsRepeat the query in quotes, suggest another way to search and offer Clear search; the search illustration.
All doneA queue that was cleared. You're all caught up, what shows up here next, no action; the done illustration.
CompactIn rails and small cards: the small illustration, a 13px title, a 12px description and an extra-small button.

Behavior#

  • Empty and its parts are static layout with no state of their own; you decide when to render them.
  • EmptyState is the opinionated version: illustration, title, description, action and size (md or sm). DataState renders it when isEmpty(data) is true and passes its size through.
  • Empty is flex-1, so in a flex column it fills the remaining height and centers its content; use that for a page-level empty below a toolbar.
  • Text is balanced (text-balance) so short titles and sentences break evenly.
  • Filtered and search empties should change back to content in place when the filter or query clears, without a reload.

Do and don't#

You're all caught up
New proposals show up here.
Do. Use an Illustration in the media slot.
You're all caught up
New proposals show up here.
Don't. Put a lucide icon in a gray tile with variant="icon". It is the refused pattern, and it reads as a placeholder.
No W-9s on file
Suppliers need a W-9 before their first payment. Request one by email.
Do. Say what the place is for and offer the first action.
No data
There's nothing here yet.
Don't. Shrug with No data. It tells the reader nothing about what belongs here or how to add it.
No invoices match these filters
Due this week and Over $10,000 hide all 212 invoices.
Do. Name the filters that hid everything and offer Clear filters.
No results
Don't. Show No results with no way out.

Content#

  • Title: what is empty, in a few words and sentence case: No payment runs scheduled, No suppliers match these filters.
  • Description: one sentence about what the place is for, or which filters hid everything. Not a paragraph.
  • For search, repeat the query in quotes: No suppliers match "Northwind Frieght".
  • Action labels start with a verb and name the object: Schedule a run, Clear filters, Request W-9s.
  • Warm but brief for first use and all-done; plain for filtered states. No jokes, no exclamation points.
  • Never park lasting information in an empty state. It disappears the moment content exists.

Accessibility#

  • The illustration is decorative (aria-hidden) unless you give it a title. The title and description carry the meaning.
  • EmptyTitle is a div. When the empty state replaces a section's content, keep the section heading above it so the page outline doesn't lose a level.
  • When filters or a search empty a list, announce the new count in a polite status region so screen reader users hear it changed.
  • Buttons in EmptyContent need visible labels that name the action.
  • Don't move focus into an empty state; focus stays on the filter or search that caused it.

Design tokens#

Design tokens
TokenUsed for
--muted-foregroundDescription text
--foregroundTitle and illustration ink
--mutedIllustration paper and the icon-variant tile
--primaryHover color of links in the description
--radius-xl12px corners, when you give it a surface

API reference#

Empty

The container: a centered, balanced column. data-slot="empty".

Other props spread onto <div>.

Props of Empty
PropTypeDefaultDescription
classNamestringNo defaultAdd the surface here, such as rounded-xl bg-card shadow-border, and any padding change.

EmptyHeader

Groups media, title and description, capped at 24rem.

Other props spread onto <div>.

Props of EmptyHeader
PropTypeDefaultDescription
classNamestringNo defaultMerged after the defaults.

EmptyMedia

Holds the illustration. data-slot="empty-icon" and data-variant.

Other props spread onto <div>.

Props of EmptyMedia
PropTypeDefaultDescription
variant"default" | "icon""default"default is a transparent slot for an Illustration. icon draws a 32px Well Gray tile for a lucide icon, which Canon refuses for empty states.
classNamestringNo defaultMerged after the variant classes.

EmptyTitle

The one-line title, 14px at weight 500.

Other props spread onto <div>.

Props of EmptyTitle
PropTypeDefaultDescription
classNamestringNo defaultUse text-[13px] to step it down; text-13 doesn't override here.

EmptyDescription

One sentence in Slate Meta. Links inside it are underlined and turn indigo on hover.

Other props spread onto <div> (typed as <p> props).

Props of EmptyDescription
PropTypeDefaultDescription
classNamestringNo defaultMerged after the defaults.

EmptyContent

The action area: a centered column 24rem wide with 10px gaps.

Other props spread onto <div>.

Props of EmptyContent
PropTypeDefaultDescription
classNamestringNo defaultAdd flex-row to set two actions side by side.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

EmptyMedia variant="icon" draws a lucide icon in a Well Gray tile, the pattern Canon refuses. All of the roughly 30 Empty call sites in the app use it (Home approvals, tasks, inbox, lists, tables, the app status screens) and none uses an Illustration.

Empty carries border-dashed but no border width, so it draws no outline. The class is inert.

EmptyTitle is weight 500 while EmptyState's title is weight 600, so the product has two empty-state title weights.

EmptyTitle and EmptyDescription render divs; EmptyDescription is typed with <p> props. Neither is a heading.

EmptyMedia keeps data-slot="empty-icon" even when it holds an illustration.