Skip to content

Illustration

Hairline spot illustrations for empty states, errors and education, animated with CSS on mount and hover.

Status
Beta
Level
Atom
Category
Content
Adoption
Not used yet
import { Illustration } from "@oration/canon/components/illustration";
packages/canon/src/components/illustration.tsx

No suppliers yet

Import your vendor list from a CSV or NetSuite, or add suppliers one at a time.
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: customers for no suppliers yet, filters for 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 sm inside 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

The art is drawn in ink steps on paper and Well Gray, with a single Ink 80 accent. It never takes indigo or a categorical hue, so it sits quietly under the title and can't be mistaken for status.

One family, one per state

Every drawing shares the flat-shape-and-edge style, the stroke and the palette, and tells one action with its motion, so they read as one set. A state gets one illustration, placed above its title and centered with it.

Anatomy#

  1. Canvas. An <svg> with a 120 by 96 viewBox at a 5:4 ratio, rendered 80, 120 or 160px wide.
  2. 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.
  3. 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.
  4. Outlines. 1px round-capped strokes with vector-effect: non-scaling-stroke, so they stay 1px at every size. Each ink step is currentColor mixed into Card White, so an edge lying over another doesn't darken.
  5. 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#

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.

sm, 80 × 64
md, 120 × 96
lg, 160 × 128
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.

InvoicesSupplier is Halcyon, status is overdue

No invoices match these filters

Halcyon has no overdue invoices.
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

NetSuite didn't answer. Try again in a moment.
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.

Your workspace is readyCedarline, 3 teammates invited, NetSuite connected.
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#

States
StateTreatment
DecorativeThe default. aria-hidden is set and the art is skipped by screen readers.
Namedtitle sets role="img" and aria-label, for the rare drawing that carries meaning the text around it doesn't.
PlayingOn 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 motionEvery animation is off and the drawing stands in its rest pose, which is its final pose. Nothing is missing.
Dark themeInk follows currentColor and paper and wells follow --card and --muted, so the drawing inverts with no extra class.

Behavior#

  • name picks the drawing from a fixed map. ILLUSTRATION_NAMES lists all 23, in the map's order.
  • size sets the rendered width to 80, 120 or 160px and the height to 80% of that. Override both with classes such as h-auto w-16 only when a composed component needs it.
  • The root has text-foreground, shrink-0 and select-none. className is merged after them.
  • Motion is CSS keyframes in globals.css, one motion per 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 on pointerenter with 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.
  • EmptyState renders md art at its md size and sm art at sm. ErrorState always uses error at sm. PageIntro uses sm at 72px and hides it below 640px.

Do and don't#

No runs scheduled

Do. Size the art to its container: sm in a card or side panel, md in a page-level empty state.

No runs scheduled

Don't. Drop a lg drawing into a small panel. It crowds the title and pushes the action below the fold.
Do. Leave the art in ink. It inverts with the theme and stays quiet under the title.
Don't. Tint it with text-primary or a status color. Indigo reads as selected, and the drawing starts competing with the one filled button.
Do. Keep it decorative and let the title say what the state is.
Don't. Give it a title that repeats the heading. Screen readers hear No suppliers yet twice.

Content#

  • Pick the drawing for the place, not the mood: customers for suppliers and contacts, filters for no matches, search for no results, reports for an empty report, done for 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 title on 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 becomes role="img" with that aria-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: reduce every animation is off and the drawing rests in its final pose.

Design tokens#

Design tokens
TokenUsed for
currentColor (text-foreground)Outlines and accent, mixed into Card White at ink steps
--cardEvery shape's fill, and the paper each ink step is mixed into
Ink 15Marks and the faded reflection
Ink 65, Ink 45, Ink 25Top outlines, edges, and marks
Ink 80The single accent
1px, non-scaling-strokeStroke 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>..

Props of Illustration
PropTypeDefaultDescription
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 defaultWhich drawing to render.
size"sm" | "md" | "lg""md"80, 120 or 160px wide, at a 5:4 ratio.
titlestringNo defaultMakes it role="img" with this aria-label. Leave it out for decorative art.
classNamestringNo defaultMerged 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.