Illustration
Hairline spot illustrations for empty states, errors and education, animated with CSS on mount and hover.
No suppliers yet
import { Button } from "@oration/canon/components/button";import { EmptyState } from "@oration/canon/components/data-state";import { toast } from "@oration/canon/components/toast";import { PlusIcon, UploadIcon } from "lucide-react";export function Hero() { return ( <div className="w-full max-w-lg rounded-xl bg-card shadow-border"> <EmptyState illustration="customers" title="No suppliers yet" description="Import your vendor list from a CSV or NetSuite, or add suppliers one at a time." action={ <> <Button type="button" variant="outline" onClick={() => toast.add({ title: "Choose a CSV", description: "Up to 5,000 rows.", }) } > <UploadIcon data-icon="inline-start" aria-hidden="true" /> Import CSV </Button> <Button type="button" onClick={() => toast.add({ title: "New supplier" })} > <PlusIcon data-icon="inline-start" aria-hidden="true" /> Add supplier </Button> </> } /> </div> );}Usage#
Illustration draws one of 23 spot drawings. Each is one subject, drawn large and flat with room round it: Card White shapes outlined at Ink 65 over a copy a few units lower at Ink 45 that shows as their edge, a 1px stroke, a faded reflection under whatever stands on the floor, and one Ink 80 accent. Each drawing acts out its subject once when it mounts and again on hover: a letter drops into the inbox, the gears turn, a check is drawn. They're drawn in currentColor and Card White, so they invert with dark mode on their own. Their job is to make an empty, error or education state recognizable at a glance, never to decorate. Most of the time you don't render one directly: pass illustration to an empty state or a page intro and it picks the size for you.
When to use
- Through Empty and Data state's
EmptyState, above the title of a first-run or no-results state:customersfor no suppliers yet,filtersfor no matches. - In a Page intro banner that explains what a new area is for.
- Directly, at
md, for a full-panel moment such as the onboarding finish (done). - At
sminside cards, side panels and table bodies, where the state has less room.
When not to use
- For a small glyph inside a button, row or menu. Use a 16px Lucide icon. Use Iconography
- For a loading state. Draw the layout that's coming instead. Use Skeleton
- For the face of a person, company or agent. Use Avatar
- As decoration on a populated page, a card header or a marketing flourish. Art only appears when there's nothing else to show.
- For an error inside a form or a failed save. Say it next to the field or in a toast. Use Feedback and undo
The Ink Fill Rule
One family, one per state
Anatomy#
- Canvas. An
<svg>with a 120 by 96viewBoxat a 5:4 ratio, rendered 80, 120 or 160px wide. - Slabs. Flat shapes seen from a little above, filled with Card White (
var(--card)) and outlined at Ink 65: the tiles, cards, discs and trays in the drawing. - Edge. The same shape again a few units lower at Ink 45, showing below the top as its thickness. Marks on surfaces sit at Ink 15 and 25.
- Outlines. 1px round-capped strokes with
vector-effect: non-scaling-stroke, so they stay 1px at every size. Each ink step iscurrentColormixed into Card White, so an edge lying over another doesn't darken. - Accent. One Ink 80 mark, such as the check or the unread dot, that the drawing is about. It comes on as the action lands.
Examples#
Every drawing
All 23 names from ILLUSTRATION_NAMES at sm. Switch the preview theme to see the family invert.
- agents
- calls
- campaigns
- connect
- conversations
- customers
- done
- error
- filters
- flows
- inbox
- knowledge
- phone
- procedures
- reports
- schedule
- scorecards
- search
- skills
- tickets
- tools
- voice
- widget
import { ILLUSTRATION_NAMES, Illustration } from "@oration/canon/components/illustration";export function Gallery() { return ( <ul className="grid w-full grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6"> {ILLUSTRATION_NAMES.map((name) => ( <li key={name} className="flex flex-col items-center gap-2 rounded-[10px] bg-muted/70 px-2 pt-3 pb-2" > <Illustration name={name} size="sm" /> <span className="font-mono text-xs text-muted-foreground"> {name} </span> </li> ))} </ul> );}Sizes
sm for cards, panels and table bodies, md for a page-level state, lg for a full-screen moment. The outline stays 1.5px at all three.
import { Illustration } from "@oration/canon/components/illustration";export function Sizes() { const sizes = [ { size: "sm", label: "sm, 80 × 64" }, { size: "md", label: "md, 120 × 96" }, { size: "lg", label: "lg, 160 × 128" }, ] as const; return ( <div className="flex flex-wrap items-end justify-center gap-10"> {sizes.map((item) => ( <figure key={item.size} className="flex flex-col items-center gap-2" > <Illustration name="inbox" size={item.size} /> <figcaption className="text-xs text-muted-foreground tabular-nums"> {item.label} </figcaption> </figure> ))} </div> );}No matches in a list
EmptyState at sm inside a filtered list, with filters art and one way out.
No invoices match these filters
import { Button } from "@oration/canon/components/button";import { EmptyState } from "@oration/canon/components/data-state";import { toast } from "@oration/canon/components/toast";export function NoMatches() { return ( <div className="w-full max-w-md rounded-xl bg-card shadow-border"> <div className="flex h-10 items-center gap-2 border-b border-border px-3 text-13 text-muted-foreground"> <span className="font-medium text-foreground">Invoices</span> <span>Supplier is Halcyon, status is overdue</span> </div> <EmptyState size="sm" illustration="filters" title="No invoices match these filters" description="Halcyon has no overdue invoices." action={ <Button type="button" variant="outline" size="sm" onClick={() => toast.add({ title: "Filters cleared" })} > Clear filters </Button> } /> </div> );}Failed load
ErrorState always draws error at a reduced size and adds a Try again button when onRetry is set.
Couldn't load remittances
import { ErrorState } from "@oration/canon/components/data-state";import { toast } from "@oration/canon/components/toast";export function FailedLoad() { return ( <div className="w-full max-w-md rounded-xl bg-card shadow-border"> <ErrorState size="sm" title="Couldn't load remittances" description="NetSuite didn't answer. Try again in a moment." onRetry={() => toast.add({ title: "Retrying", type: "info" })} /> </div> );}Named art
Pass title only when the drawing carries meaning of its own. Here it is described for screen readers, and the caption carries the state.
import { Illustration } from "@oration/canon/components/illustration";export function Named() { return ( <figure className="flex flex-col items-center gap-3 text-center"> <Illustration name="done" size="md" title="A checklist with every item done" /> <figcaption className="flex flex-col gap-1"> <span className="text-sm font-semibold text-foreground"> Your workspace is ready </span> <span className="text-13 text-muted-foreground"> Cedarline, 3 teammates invited, NetSuite connected. </span> </figcaption> </figure> );}States#
| State | Treatment |
|---|---|
| Decorative | The default. aria-hidden is set and the art is skipped by screen readers. |
| Named | title sets role="img" and aria-label, for the rare drawing that carries meaning the text around it doesn't. |
| Playing | On mount, and each time the pointer comes onto it, the drawing acts out its subject over about a second and a half, then rests in the pose it was drawn in. |
| Reduced motion | Every animation is off and the drawing stands in its rest pose, which is its final pose. Nothing is missing. |
| Dark theme | Ink follows currentColor and paper and wells follow --card and --muted, so the drawing inverts with no extra class. |
Behavior#
namepicks the drawing from a fixed map.ILLUSTRATION_NAMESlists all 23, in the map's order.sizesets the rendered width to 80, 120 or 160px and the height to 80% of that. Override both with classes such ash-auto w-16only when a composed component needs it.- The root has
text-foreground,shrink-0andselect-none.classNameis merged after them. - Motion is CSS keyframes in
globals.css, onemotionper mark (drop,slide,hop,rise,grow,spin,ripple,press,pulse,bob,lift,pop,shake,draw,light). No library and no frame loop: the root is a small client canvas that replays its animations onpointerenterwith the Web Animations API. - Shapes are path strings from the kit in
illustrations/primitives.tsx(rrect,ellipse,rhombus,hexagon,wedge,cube,gear), worked out once when the drawings' module loads. No camera; rendering is plain SVG. focusable="false"keeps the SVG out of the tab order in every browser.EmptyStaterendersmdart at itsmdsize andsmart atsm.ErrorStatealways useserroratsm.PageIntrousessmat 72px and hides it below 640px.
Do and don't#
No runs scheduled
sm in a card or side panel, md in a page-level empty state.No runs scheduled
lg drawing into a small panel. It crowds the title and pushes the action below the fold.text-primary or a status color. Indigo reads as selected, and the drawing starts competing with the one filled button.title that repeats the heading. Screen readers hear No suppliers yet twice.Content#
- Pick the drawing for the place, not the mood:
customersfor suppliers and contacts,filtersfor no matches,searchfor no results,reportsfor an empty report,donefor a finished flow. - The title under it says what the place is for or why it's empty: No suppliers yet, No invoices match these filters.
- A
titleon the art, when you need one, describes the picture, not the state: A checklist with every item done.
Accessibility#
- Decorative by default:
aria-hidden="true"and no role, so the state's text carries everything. - With
title, the SVG becomesrole="img"with thataria-label. focusable="false"stops legacy browsers from tabbing into the SVG.- Outline contrast comes from ink steps of
currentColor, which follow the theme. Don't lower it with opacity. - Under
prefers-reduced-motion: reduceevery animation is off and the drawing rests in its final pose.
Design tokens#
| Token | Used for |
|---|---|
currentColor (text-foreground) | Outlines and accent, mixed into Card White at ink steps |
--card | Every shape's fill, and the paper each ink step is mixed into |
Ink 15 | Marks and the faded reflection |
Ink 65, Ink 45, Ink 25 | Top outlines, edges, and marks |
Ink 80 | The single accent |
1px, non-scaling-stroke | Stroke weight at every size |
API reference#
Illustration
One spot drawing. Also exported: ILLUSTRATION_NAMES and the IllustrationName and IllustrationSize types.
Other props spread onto Nothing. Only the props below reach the <svg>..
| Prop | Type | Default | Description |
|---|---|---|---|
nameRequired | "agents" | "calls" | "campaigns" | "connect" | "conversations" | "customers" | "done" | "error" | "filters" | "flows" | "inbox" | "knowledge" | "phone" | "procedures" | "reports" | "schedule" | "scorecards" | "search" | "skills" | "tickets" | "tools" | "voice" | "widget" | No default | Which drawing to render. |
size | "sm" | "md" | "lg" | "md" | 80, 120 or 160px wide, at a 5:4 ratio. |
title | string | No default | Makes it role="img" with this aria-label. Leave it out for decorative art. |
className | string | No default | Merged after shrink-0 text-foreground select-none. |
ILLUSTRATION_NAMES
Every valid name, as IllustrationName[].
No props of its own.
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
ErrorState shrinks the art to 64px with h-auto w-16 at its small size, and PageIntro to 72px with w-18. Neither is on the 80, 120, 160 ramp.
Only className is forwarded, so an id, aria-describedby or data-* attribute can't be set on the SVG.