Empty
The primitives for an empty state: illustration, title, one sentence and the next action.
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
EmptyStatecan'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, orDataState'semptyprop. 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
Illustrations, not icon circles
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#
- Container.
Empty: a centered column with 16px gaps and 24px padding. Give it the surface it sits on, such asrounded-xl bg-card shadow-border, or none inside a card. - Media.
EmptyMediaholding anIllustration: 120px wide by default, 80px (size="sm") in rails and small cards. - Title.
EmptyTitle, 14px at weight 500. What is empty, in a few words: No payment runs scheduled. - Description.
EmptyDescription, 14px Slate Meta, up to 24rem wide. One sentence about what the place is for, or what hid everything. - 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
No scorecards yet
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.
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.
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.
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.
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#
| State | Treatment |
|---|---|
| First use | Nothing has been created yet. Say what the place is for, give one example if it helps, and offer the first action. |
| Filtered to nothing | Filters hide every record. Name the filters and offer Clear filters; the filters illustration. |
| No search results | Repeat the query in quotes, suggest another way to search and offer Clear search; the search illustration. |
| All done | A queue that was cleared. You're all caught up, what shows up here next, no action; the done illustration. |
| Compact | In 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.
EmptyStateis the opinionated version:illustration,title,description,actionandsize(mdorsm).DataStaterenders it whenisEmpty(data)is true and passes itssizethrough.Emptyisflex-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#
Illustration in the media slot.variant="icon". It is the refused pattern, and it reads as a placeholder.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 atitle. The title and description carry the meaning. EmptyTitleis adiv. 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
EmptyContentneed 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#
| Token | Used for |
|---|---|
--muted-foreground | Description text |
--foreground | Title and illustration ink |
--muted | Illustration paper and the icon-variant tile |
--primary | Hover color of links in the description |
--radius-xl | 12px corners, when you give it a surface |
API reference#
Empty
The container: a centered, balanced column. data-slot="empty".
Other props spread onto <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | No default | Add 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>.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | No default | Merged after the defaults. |
EmptyMedia
Holds the illustration. data-slot="empty-icon" and data-variant.
Other props spread onto <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
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. |
className | string | No default | Merged after the variant classes. |
EmptyTitle
The one-line title, 14px at weight 500.
Other props spread onto <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | No default | Use 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).
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | No default | Merged after the defaults. |
EmptyContent
The action area: a centered column 24rem wide with 10px gaps.
Other props spread onto <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | No default | Add 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.