Skip to content

Illustration

Neat spot drawings, one subject each: flat Card White shapes with an offset edge, a 1px stroke and one Ink 80 accent, each acting out its subject on mount and on hover.

One family#

23 spot drawings that share a canvas, a stroke and a palette. They make an empty, error or education state recognizable at a glance, and nothing else.

  1. Canvas. A 120 by 96 viewBox, a 5:4 ratio, rendered 80, 120 or 160px wide.
  2. Slabs. Every object is a flat shape seen from a little above: a tile, a card, a disc. Card White (var(--card)) with a 1px outline at Ink 65, drawn large with room round it.
  3. Edge. The same shape again a few units lower, at Ink 45, showing below the top as the object's thickness. That one offset copy is all the depth a drawing has: no isometric box, no shading.
  4. Marks. Strokes and dots at Ink 15 and 25 standing in for text, rows and data, on the surfaces they belong to. No real words.
  5. Accent. One Ink 80 mark, here the check, for what the drawing is about. It comes on as the drawing's action lands. Never a second one, and never a hue.

The family takes its ink and its 1px stroke from the hairline figures, but not their camera. Each drawing is one subject, drawn large and flat, so it reads at 80px. Every mark is Graphite Ink at an ink step, or Card White, so a drawing inverts with the theme without a dark variant. Ink steps are mixed into Card White with color-mix, so where a top edge lies over its lower copy the line doesn't darken. Strokes use vector-effect: non-scaling-stroke, so they stay 1px at every size. Whatever stands on the floor carries a faded reflection at Ink 15.

How they're drawn#

The kit in packages/canon/src/components/illustrations/primitives.tsx. A drawing is a few flat shapes on the 120 by 96 grid: a shape helper returns a path string, and a mark draws it. Plain SVG, no camera, no hand-picked colors and no frame loop.

The primitives

Shapes are path strings (rrect, ellipse, rhombus, hexagon, polygon, path, wedge, cube, gear); marks draw them. Each mark takes an ink step, and most take one motion.

  • Slab
  • Disc
  • Cube
  • Line
  • Fill
  • Group
  • At
  • Reflection
import { ArtCanvas } from "@oration/canon/components/illustrations/canvas";import {  At,  Cube,  Disc,  ellipse,  Fill,  Group,  gear,  Line,  path,  Reflection,  rrect,  Slab,} from "@oration/canon/components/illustrations/primitives";export function Primitives() {    const parts = [        { name: "Slab", art: <Slab d={rrect(24, 8, 32, 22, 5)} /> },        {            name: "Disc",            art: <Disc cx={40} cy={18} rx={16} ry={8} depth={4} />,        },        { name: "Cube", art: <Cube cx={40} cy={20} s={12} /> },        {            name: "Line",            art: (                <>                    <Line d={rrect(22, 8, 36, 24, 5)} />                    <Line                        d={path([                            [28, 16],                            [50, 16],                        ])}                        ink={15}                    />                    <Line                        d={path([                            [28, 24],                            [52, 24],                        ])}                        dashed                    />                </>            ),        },        {            name: "Fill",            art: <Fill d={ellipse(40, 20, 5)} ink={80} motion="pop" />,        },        {            name: "Group",            art: (                <Group motion="drop" vars={{ "--y": "-8px" }}>                    <Slab d={rrect(26, 10, 28, 18, 5)} lightAt={600} />                </Group>            ),        },        {            name: "At",            art: (                <At                    x={40}                    y={20}                    motion="spin"                    dur={1400}                    vars={{ "--r": "180deg" }}                >                    <Slab d={gear(8, 13)} depth={0} />                </At>            ),        },        {            name: "Reflection",            art: (                <>                    <Reflection d={rrect(26, 6, 28, 18, 5)} bottom={24} />                    <Slab d={rrect(26, 6, 28, 18, 5)} />                </>            ),        },    ];    return (        <ul className="grid w-full max-w-2xl grid-cols-2 gap-3 sm:grid-cols-4">            {parts.map((part) => (                <li                    key={part.name}                    className="flex flex-col items-center gap-2 rounded-[10px] bg-muted/70 p-3"                >                    <ArtCanvas                        data-slot="illustration"                        viewBox="0 0 80 40"                        width={120}                        height={60}                        fill="none"                        aria-hidden="true"                        className="text-foreground"                    >                        {part.art}                    </ArtCanvas>                    <code className="font-mono text-xs text-foreground">                        {part.name}                    </code>                </li>            ))}        </ul>    );}
Illustration primitives
PrimitiveDrawsDefaults
SlabA shape with a thickness: a copy depth lower at Ink 45 for its edge, then the shape on top. lightAt brings the top outline up to Ink 80 at that moment.Card White, Ink 65 top, Ink 45 edge, depth 3.
DiscA round slab seen from a little above: an elliptical top over its sides and front rim. Score pies, contacts, a hub.Depth 3.
CubeA small cube from cube(): its hexagon and the three edges that meet at the near corner. Stack them for blocks.Ink 65 outline, Ink 25 creases.
LineA stroke: rows of text, wires, guides, a check. draw traces it in; dashed makes it a guide.Ink 25, 1px, no fill.
FillA filled mark: a dot, a status wedge, a glyph.Ink 45.
GroupMarks that move as one, with a motion, a start time at, a duration dur, a count times and vars such as --y for how far a drop falls.No motion.
AtMarks drawn round a point, so a spin turns about it and a ripple spreads from it: gears, clock hands, a campaign's wave.Origin at x, y.
ReflectionA faded, mirrored copy of a grounded slab's outline, just below it.Ink 15, masked by the canvas's fade.
The done drawing, from states.tsx
/** Cleared: the last tile settles onto the stack, and a check is drawn on it. */const Done: Art = () => (    <>        <Reflection d={doneBase} bottom={71} />        <Slab d={doneBase} />        <Group motion="drop" vars={{ "--y": "-14px" }}>            <Slab d={doneTop} />            <Line d={ellipse(60, 40, 13, 7.3)} draw at={420} dur={420} />            <Line                d={path([                    [53, 40],                    [58, 42.8],                    [68, 36.6],                ])}                ink={80}                draw                at={720}                dur={380}            />        </Group>    </>);

Motion#

Each drawing acts out its subject once when it mounts, and again whenever the pointer comes onto it. A letter drops into the tray, the gears turn, a check is drawn. Never a plain fade.

Illustration motions
MotionWhat it doesUsed for
dropFalls under gravity, lands, gives a little and settles.A letter into the tray, a tile onto the stack, a block into its slot.
slideTravels from an offset, runs a hair past and seats.A lens down a list, tickets into the queue.
hopPicked up, carried in an arc, set down.A card carried from one place to another.
riseGrows up out of its base and overshoots a little.Chart bars.
growOpens out of a point.The widget panel out of its launcher.
spinTurns back to its rest angle with a spring's overshoot, about the point At sets.Gears, clock hands.
rippleA wave spreading from a point and fading.A campaign going out.
press, pulse, bob, liftSmall returns: a button down and up, a level bar dipping about --o, a dot bobbing, a contact stepping up.Play and launcher buttons, voice levels, typing, campaign contacts.
pop, shakeA mark coming on; a damped wobble when something is wrong.Unread dots, dialled keys, the error badge.
draw, lightA stroke traced from its start; the one bright edge coming on.Checks, links, routes, scores; the accent as the action lands.

The motion is CSS keyframes in globals.css, with no library and no frame loop. A mark's drawn position is its rest pose, and every motion ends there. On hover the canvas replays its animations with the Web Animations API (cancel() then play()). Marks are sequenced with at so a drawing reads as one action, finishing in about a second and a half.

Sizes#

Three widths at a fixed 5:4 ratio. The state component usually picks one for you.

sm, md and lg

sm, 80 by 64pxCards, panels, table bodies
md, 120 by 96pxPage-level empty states
lg, 160 by 128pxFull-panel moments
import { Illustration } from "@oration/canon/components/illustration";export function Sizes() {    const sizes = [        { size: "sm", width: 80, use: "Cards, panels, table bodies" },        { size: "md", width: 120, use: "Page-level empty states" },        { size: "lg", width: 160, use: "Full-panel moments" },    ] as const;    return (        <div className="flex flex-wrap items-end justify-center gap-10">            {sizes.map((item) => (                <div                    key={item.size}                    className="flex flex-col items-center gap-3"                >                    <Illustration name="reports" size={item.size} />                    <div className="flex flex-col items-center">                        <span className="text-13 font-medium tabular-nums">                            {item.size}, {item.width} by {item.width * 0.8}px                        </span>                        <span className="text-xs text-muted-foreground">                            {item.use}                        </span>                    </div>                </div>            ))}        </div>    );}
Illustration sizes
SizeRenderedWhere
sm80 by 64pxCards, side panels, sheets and table bodies. EmptyState size="sm" and ErrorState.
md120 by 96pxThe default. Page-level empty states and first runs, through EmptyState.
lg160 by 128pxA full panel with nothing else in it, such as the end of onboarding.

ErrorState shrinks its art to 64px at its small size and PageIntro to 72px, both off the 80, 120 and 160 ramp. Follow the ramp in new work.

Where they go#

Empty states, errors and education. A drawing appears only when there is nothing else to show, and never as decoration on a page that has content.

Empty, no matches and failed

A first-run state at md with its one filled action, a filtered-to-nothing state at sm and a failed load with a retry.

No suppliers yet

Import your vendor list from NetSuite or add suppliers one at a time.

No invoices match these filters

Try a wider date range or clear the supplier filter.

Couldn't load payment runs

The connection to NetSuite timed out.
import { Button } from "@oration/canon/components/button";import { EmptyState, ErrorState } from "@oration/canon/components/data-state";import { toast } from "@oration/canon/components/toast";export function InContext() {    return (        <div className="grid w-full max-w-3xl gap-4 md:grid-cols-2">            <div className="rounded-xl bg-card shadow-border">                <EmptyState                    illustration="customers"                    title="No suppliers yet"                    description="Import your vendor list from NetSuite or add suppliers one at a time."                    action={                        <Button                            type="button"                            onClick={() =>                                toast.add({ title: "Import started" })                            }                        >                            Import suppliers                        </Button>                    }                />            </div>            <div className="flex flex-col gap-4">                <div className="rounded-xl bg-card shadow-border">                    <EmptyState                        size="sm"                        illustration="filters"                        title="No invoices match these filters"                        description="Try a wider date range or clear the supplier filter."                        action={                            <Button                                type="button"                                variant="outline"                                size="sm"                                onClick={() =>                                    toast.add({ title: "Filters cleared" })                                }                            >                                Clear filters                            </Button>                        }                    />                </div>                <div className="rounded-xl bg-card shadow-border">                    <ErrorState                        size="sm"                        title="Couldn't load payment runs"                        description="The connection to NetSuite timed out."                        onRetry={() => toast.add({ title: "Retrying" })}                    />                </div>            </div>        </div>    );}
No payment runs scheduledSchedule a run to pay approved invoices on a set day.
Do. Pair the drawing with a title that says what the place is for, one sentence and the next action.
No payment runs scheduledSchedule a run to pay approved invoices on a set day.
Don't. Bare lucide icons in circles as empty states. Empty states use an Illustration, a title, one sentence and the next action.
Friday payment run212 invoices, $1,284,310.42
Oct 2
Do. Let a populated card carry its data. The date or amount is what someone scans for.
Friday payment run212 invoices, $1,284,310.42
Don't. Add a drawing to brighten a card that has content. It crowds the figures and competes with the action.
  • Empty. Through Empty or Data state's EmptyState: a first run, a cleared queue, a filter with no matches.
  • Error. ErrorState always draws error. Errors in a form or a failed save are said beside the field or in a toast, with no drawing. See Feedback.
  • Education. A Page intro banner that explains a new area, hidden below 640px.
  • Loading never gets a drawing. Draw the layout that's coming with a Skeleton.
  • Leave the art in ink. Don't tint it with text-primary or a status color.
Placing an illustration
import { EmptyState } from "@oration/canon/components/data-state";import { Illustration } from "@oration/canon/components/illustration";// Usually through a state component, which picks the size<EmptyState  illustration="customers"  title="No suppliers yet"  description="Import your vendor list from NetSuite or add suppliers one at a time."  action={<Button>Import suppliers</Button>}/>// Directly, for a full-panel moment<Illustration name="done" size="md" />

Which drawing for which state#

Pick the drawing for the place, not the mood. These are the pairings the product already uses.

Empty states and their illustrations
StateExample titleIllustration
No matches for the current filtersNo invoices match these filtersfilters
No results for a searchNo suppliers match “Halcyon”search
A record that doesn't exist, with no drawing of its ownThis supplier doesn't existsearch
No people, suppliers, members or requests yetNo suppliers yetcustomers
A queue or checklist clearedYou're all caught updone
A panel failed to loadCouldn't load payment runserror
A report or chart with no dataNo remittances in this periodreports
Nothing scheduled, no runs yetNo payment runs scheduledschedule
No conversations, events or examplesNo remittance emails yetconversations
Not connected: SSO, a workspace, an integrationNetSuite isn't connectedconnect
A feature that's off, or no toolsApprovals aren't turned on for Cedarlinetools
No numbers, transfers or callbacksNo transfer destinationsphone
Nothing scoredThis call wasn't scoredscorecards
No test cases, evaluation runs or queuesNo test cases yetflows
No procedures or macrosDesign your first procedureprocedures
No variables or knowledge sourcesNo custom variablesknowledge

A kind of record with its own drawing keeps it for every state: agents, campaigns, skills, tickets, calls, voice terms and the widget use agents, campaigns, skills, tickets, calls, voice and widget for their empty, missing and first-run states alike.

Adding a drawing#

Add one only when a new kind of place has no drawing that fits. Most new screens reuse a state drawing.

A draft built from primitives

A card with two lines of text and a dashed total, and a badge that drops onto it and is checked in Ink 80. Nothing in it is a raw color.

import { ArtCanvas } from "@oration/canon/components/illustrations/canvas";import { ellipse, Group, Line, path, Reflection, rrect, Slab } from "@oration/canon/components/illustrations/primitives";export function NewDrawing() {    const card = rrect(30, 24, 60, 42, 6);    return (        <ArtCanvas            data-slot="illustration"            viewBox="0 0 120 96"            width={160}            height={128}            fill="none"            aria-hidden="true"            className="text-foreground"        >            <Reflection d={card} bottom={66} />            <Slab d={card} />            <Line                d={path([                    [40, 37],                    [68, 37],                ])}            />            <Line                d={path([                    [40, 45],                    [60, 45],                ])}                ink={15}            />            <Line                d={path([                    [40, 55],                    [80, 55],                ])}                dashed            />            <Group motion="drop" at={300} vars={{ "--y": "-14px" }}>                <Slab d={ellipse(82, 28, 7)} depth={2} lightAt={760} />                <Line                    d={path([                        [78.8, 28.2],                        [81.2, 30.6],                        [85.4, 25.8],                    ])}                    ink={80}                    draw                    at={760}                    dur={320}                />            </Group>        </ArtCanvas>    );}
  1. Draw it as an Art in the file it belongs to (states, work or build), on the 120 by 96 grid, using only the primitives.
  2. Keep it to one subject about two thirds of the canvas wide, use one Ink 80 accent, stand in for words with lines, and give the drawing one action told with motions in sequence.
  3. Register it in ILLUSTRATIONS in illustration.tsx. It joins ILLUSTRATION_NAMES, the type and the gallery above on its own.
  4. Check it at sm in both themes. If the subject doesn't read at 80px, simplify it.
Registering a drawing
// packages/canon/src/components/illustration.tsximport { Invoices } from "@oration/canon/components/illustrations/work";const ILLUSTRATIONS = {  …  inbox: Inbox,  invoices: Invoices, // keep the map alphabetical  knowledge: Knowledge,  …} satisfies Record<string, Art>;

Accessibility#

Illustrations are decorative. The title under them says everything.

  • By default the SVG is aria-hidden with no role, and focusable="false" keeps it out of the tab order.
  • Pass title only when the picture carries meaning the text doesn't. It becomes role="img" with that aria-label; describe the picture, not the state.
  • Don't give it a title that repeats the heading. Screen readers would hear No suppliers yet twice.
  • Contrast comes from the ink steps, which follow the theme. Don't fade the art with opacity.
  • Under prefers-reduced-motion: reduce every animation is off and the drawing stands in its rest pose, which is the pose it was drawn in. Nothing is lost.

See Illustration for the props and Data states for how empty, loading and error states are put together.