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.
- Canvas. A 120 by 96
viewBox, a 5:4 ratio, rendered 80, 120 or 160px wide. - 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. - 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.
- 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.
- 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.
The full set#
Every name in ILLUSTRATION_NAMES, in both themes, grouped by the file it lives in.
All illustrations
Rendered at sm. The light and dark panels use the same components; only the tokens change.
Light
States
doneerrorfiltersreportssearch
Work
callscampaignsconversationscustomersinboxphonescheduleticketswidget
Build
agentsconnectflowsknowledgeproceduresscorecardsskillstoolsvoice
Dark
States
doneerrorfiltersreportssearch
Work
callscampaignsconversationscustomersinboxphonescheduleticketswidget
Build
agentsconnectflowsknowledgeproceduresscorecardsskillstoolsvoice
import { ILLUSTRATION_NAMES, Illustration } from "@oration/canon/components/illustration";import { cn } from "@oration/canon/lib/utils";export function Gallery() { const groups: { name: string; members: string[] }[] = [ { name: "States", members: ["done", "error", "filters", "reports", "search"], }, { name: "Work", members: [ "calls", "campaigns", "conversations", "customers", "inbox", "phone", "schedule", "tickets", "widget", ], }, { name: "Build", members: [ "agents", "connect", "flows", "knowledge", "procedures", "scorecards", "skills", "tools", "voice", ], }, ]; const grouped = new Set(groups.flatMap((group) => group.members)); const rest = ILLUSTRATION_NAMES.filter((name) => !grouped.has(name)); const sections = [ ...groups.map((group) => ({ name: group.name, names: ILLUSTRATION_NAMES.filter((name) => group.members.includes(name), ), })), ...(rest.length ? [{ name: "Other", names: rest }] : []), ]; return ( <div className="grid w-full min-w-0 gap-px overflow-hidden rounded-[10px] bg-border xl:grid-cols-2"> {(["light", "dark"] as const).map((theme) => ( <div key={theme} className={cn( theme, "flex min-w-0 flex-col gap-5 bg-background p-5 text-foreground", )} > <p className="text-xs text-muted-foreground"> {theme === "light" ? "Light" : "Dark"} </p> {sections.map((section) => ( <section key={section.name} className="flex flex-col gap-2" > <h4 className="text-13 font-medium"> {section.name} </h4> <ul className="grid grid-cols-3 gap-2 sm:grid-cols-5"> {section.names.map((name) => ( <li key={name} className="flex flex-col items-center gap-1.5 rounded-[10px] py-2" > <Illustration name={name} size="sm" /> <code className="font-mono text-2xs text-muted-foreground"> {name} </code> </li> ))} </ul> </section> ))} </div> ))} </div> );}| File | Drawings |
|---|---|
illustrations/states.tsx | done, error, filters, reports, search. Generic states that fit anywhere. |
illustrations/work.tsx | calls, campaigns, conversations, customers, inbox, phone, schedule, tickets, widget. The things people work through. |
illustrations/build.tsx | agents, connect, flows, knowledge, procedures, scorecards, skills, tools, voice. The things people configure. |
illustrations/primitives.tsx | The drawing kit every file above is built from. |
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.
SlabDiscCubeLineFillGroupAtReflection
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> );}| Primitive | Draws | Defaults |
|---|---|---|
Slab | A 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. |
Disc | A round slab seen from a little above: an elliptical top over its sides and front rim. Score pies, contacts, a hub. | Depth 3. |
Cube | A 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. |
Line | A stroke: rows of text, wires, guides, a check. draw traces it in; dashed makes it a guide. | Ink 25, 1px, no fill. |
Fill | A filled mark: a dot, a status wedge, a glyph. | Ink 45. |
Group | Marks 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. |
At | Marks 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. |
Reflection | A faded, mirrored copy of a grounded slab's outline, just below it. | Ink 15, masked by the canvas's fade. |
/** 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.
| Motion | What it does | Used for |
|---|---|---|
drop | Falls under gravity, lands, gives a little and settles. | A letter into the tray, a tile onto the stack, a block into its slot. |
slide | Travels from an offset, runs a hair past and seats. | A lens down a list, tickets into the queue. |
hop | Picked up, carried in an arc, set down. | A card carried from one place to another. |
rise | Grows up out of its base and overshoots a little. | Chart bars. |
grow | Opens out of a point. | The widget panel out of its launcher. |
spin | Turns back to its rest angle with a spring's overshoot, about the point At sets. | Gears, clock hands. |
ripple | A wave spreading from a point and fading. | A campaign going out. |
press, pulse, bob, lift | Small 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, shake | A mark coming on; a damped wobble when something is wrong. | Unread dots, dialled keys, the error badge. |
draw, light | A 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
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> );}| Size | Rendered | Where |
|---|---|---|
sm | 80 by 64px | Cards, side panels, sheets and table bodies. EmptyState size="sm" and ErrorState. |
md | 120 by 96px | The default. Page-level empty states and first runs, through EmptyState. |
lg | 160 by 128px | A 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
No invoices match these filters
Couldn't load payment runs
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> );}- Empty. Through Empty or Data state's
EmptyState: a first run, a cleared queue, a filter with no matches. - Error.
ErrorStatealways drawserror. 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-primaryor a status color.
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.
| State | Example title | Illustration |
|---|---|---|
| No matches for the current filters | No invoices match these filters | filters |
| No results for a search | No suppliers match “Halcyon” | search |
| A record that doesn't exist, with no drawing of its own | This supplier doesn't exist | search |
| No people, suppliers, members or requests yet | No suppliers yet | customers |
| A queue or checklist cleared | You're all caught up | done |
| A panel failed to load | Couldn't load payment runs | error |
| A report or chart with no data | No remittances in this period | reports |
| Nothing scheduled, no runs yet | No payment runs scheduled | schedule |
| No conversations, events or examples | No remittance emails yet | conversations |
| Not connected: SSO, a workspace, an integration | NetSuite isn't connected | connect |
| A feature that's off, or no tools | Approvals aren't turned on for Cedarline | tools |
| No numbers, transfers or callbacks | No transfer destinations | phone |
| Nothing scored | This call wasn't scored | scorecards |
| No test cases, evaluation runs or queues | No test cases yet | flows |
| No procedures or macros | Design your first procedure | procedures |
| No variables or knowledge sources | No custom variables | knowledge |
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> );}- Draw it as an
Artin the file it belongs to (states, work or build), on the 120 by 96 grid, using only the primitives. - 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.
- Register it in
ILLUSTRATIONSinillustration.tsx. It joinsILLUSTRATION_NAMES, the type and the gallery above on its own. - Check it at
smin both themes. If the subject doesn't read at 80px, simplify it.
// 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-hiddenwith no role, andfocusable="false"keeps it out of the tab order. - Pass
titleonly when the picture carries meaning the text doesn't. It becomesrole="img"with thataria-label; describe the picture, not the state. - Don't give it a
titlethat 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: reduceevery 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.