Hairline illustration
Interactive isometric figures in one 1px stroke, six studies of motion and one concept for each idea in the suite, that answer the pointer with springs and rest in a drawn pose.
- Status
- Experimental
- Level
- Molecule
- Category
- Content
- Adoption
- Not used yet
import { Hairline } from "@oration/canon/components/hairline";packages/canon/src/components/hairline.tsx- Rest
- Rest
import { HAIRLINE_STUDIES, Hairline } from "@oration/canon/components/hairline";export function Hero() { return ( <ul className="grid w-full gap-3 sm:grid-cols-2 lg:grid-cols-3"> {HAIRLINE_STUDIES.map((name) => ( <li key={name}> <Hairline name={name} caption={name.charAt(0).toUpperCase() + name.slice(1)} hint={false} /> </li> ))} </ul> );}Usage#
Hairline illustrations are interactive figures that explain an idea by letting someone handle it. Six are studies of motion: a tray of folder-tab pages that riffle, a field that rises under the pointer, an app window taken apart into its screen sections, a dot matrix you can paint, a belt whose clock slows down, and a turntable you can flick. Eighteen more are concept figures, one for each idea in the suite, grouped by the app it lives in: customers, campaigns and integrations in CRM; calls, conversations, phone numbers, business hours and the widget in Contact Center; tickets and the inbox in Ticketing; and agents, flows, knowledge, procedures, scorecards, skills, tools and voice in the Agents Platform. They share one grammar: one projection, one 1px stroke, rounded solids with a single dim crease inside a brighter outline, no words inside the drawing, and one stroke that turns bright when something is active. They're for education and explanation, never for empty states, and every one of them rests in a pose that was drawn on purpose.
When to use
- On a page that explains how something works, where handling the idea teaches it faster than a paragraph: Exploded beside a note on how a screen is layered, Slow beside one on throughput.
- In a feature announcement or a Page intro-sized moment that has room for a 320px-wide stage and one sentence of context.
- In the design docs and the handbook, to show a motion or interaction principle someone can feel.
- A concept figure beside the introduction to the area it names:
knowledgeabove a first look at knowledge sources,flowsin the tour of the flow builder,ticketsin a Ticketing announcement. - Through
Hairlinewith anamewhen you want a figure with its default settings, or through the named component when you need its prop.
When not to use
- For an empty, error or first-run state. Those get a static spot drawing that sits quietly under the title. Use Illustration
- For loading. Draw the layout that's coming. Use Skeleton
- To show data. The heights, layers and crates here are made up; a reader will try to read them as numbers. Use Chart
- As decoration on a dense working screen, a table or a card that has content. A figure that moves under the pointer competes with every control near it.
- Below about 280px wide. The corners of chrome start to collide and the solids lose their crease.
The One Bright Edge Rule
The Drawn Rest Rule
The Fixed Target Rule
The No Words Rule
Anatomy#
- Stage. A 5:4 frame with 12px corners on its own ground,
--hl-ground, and a 400 by 320 SVG inside it. It fills its container's width. - Silhouette. Each solid's outline, its projected hull with every corner rounded, at Ink 45. Faces fill with the ground, so a solid hides what is behind it without looking filled.
- Crease. One dim line inside the silhouette at Ink 25: the edge where the solid's cap folds into its sides. It meets the outline halfway round a rounded corner.
- Bright edge. The one outline that turns Ink 80 when its solid is active: the pulled page, the picked layer, the crate under the pointer.
- Readout. Bottom right, 12px medium tabular figures in Graphite Ink: the name, rate or angle the figure is showing.
- Hint. Bottom left, 12px Slate Meta: how to use the figure, such as Hover or use ← →. Linked to the stage with
aria-describedby.
Examples#
Riffle
A tray of folder-tab pages, one per figure in the set. The page under the pointer stands up and the others lean away from it, one after another, counted outwards from the pointer rather than down the list. Press Hit bands in the corner to see why it stays calm: the targets are fixed strips that never move with the pages.
import { HairlineRiffle } from "@oration/canon/components/hairline";import { SliderField } from "@oration/canon/components/slider-field";import * as React from "react";export function Riffle() { const [stagger, setStagger] = React.useState(40); return ( <div className="flex w-full max-w-[520px] flex-col gap-4"> <HairlineRiffle stagger={stagger} /> <SliderField label="Stagger" value={stagger} onChange={setStagger} min={0} max={90} step={5} defaultValue={40} format={(v) => `${v} ms`} /> </div> );}Terrain
Eighty-one pillars on a plinth. The pointer is cast onto the ground and each pillar rises by its distance to that point, each on its own spring, because the target moves every frame. At rest the field lies low with one rise on its lower-left side, and it settles back there when the pointer leaves.
import { HairlineTerrain } from "@oration/canon/components/hairline";import { SliderField } from "@oration/canon/components/slider-field";import * as React from "react";export function Terrain() { const [radius, setRadius] = React.useState(3); return ( <div className="flex w-full max-w-[520px] flex-col gap-4"> <HairlineTerrain radius={radius} /> <SliderField label="Radius" value={radius} onChange={setRadius} min={1.5} max={5} step={0.25} defaultValue={3} format={(v) => `${v.toFixed(2)} cells`} /> </div> );}Exploded
An app window taken apart into its screen sections: the plane, a sidebar, the content area and a popover over it, each only as big as the part of the screen it is. Moving across opens the gap and moving down picks a layer. The picked layer's edge is the whole callout; its name goes to the readout. Dashed guides drop from each corner to whatever sits under it, painted between layers so a plate hides the guides behind it.
import { HairlineExploded } from "@oration/canon/components/hairline";import { SliderField } from "@oration/canon/components/slider-field";import * as React from "react";export function Exploded() { const [maxGap, setMaxGap] = React.useState(28); return ( <div className="flex w-full max-w-[520px] flex-col gap-4"> <HairlineExploded maxGap={maxGap} /> <SliderField label="Max gap" value={maxGap} onChange={setMaxGap} min={12} max={40} defaultValue={28} format={(v) => `${v} units`} /> </div> );}Phosphor
A dot matrix on a tile floating over its base slab, tied to it by dashed corner guides, with the slab's reflection fading out below. The matrix plays a loop until the pointer lands on the tile. Then the pointer paints and every lit dot fades over the afterglow. The loop comes back a moment after the pointer leaves.
import { HairlinePhosphor } from "@oration/canon/components/hairline";import { SliderField } from "@oration/canon/components/slider-field";import * as React from "react";export function Phosphor() { const [afterglow, setAfterglow] = React.useState(520); return ( <div className="flex w-full max-w-[520px] flex-col gap-4"> <HairlinePhosphor afterglow={afterglow} /> <SliderField label="Afterglow" value={afterglow} onChange={setAfterglow} min={150} max={1500} step={10} defaultValue={520} format={(v) => `${v} ms`} /> </div> );}Slow
Crates ride a belt through a gate. Each one grows in at the near end of the belt and shrinks away at the far end, so nothing slides in from off the stage. Hovering doesn't pause them; it springs the clock down to a fraction of its speed, slow enough to read the dots on a lid. The crate under the pointer lifts, takes the bright edge and gives its number to the readout.
import { HairlineSlow } from "@oration/canon/components/hairline";import { SliderField } from "@oration/canon/components/slider-field";import * as React from "react";export function Slow() { const [slowTo, setSlowTo] = React.useState(0.2); return ( <div className="flex w-full max-w-[520px] flex-col gap-4"> <HairlineSlow slowTo={slowTo} /> <SliderField label="Slow to" value={slowTo} onChange={setSlowTo} min={0.05} max={0.6} step={0.05} defaultValue={0.2} format={(v) => `${v.toFixed(2)}×`} /> </div> );}Turntable
Here the camera is what you hold. Drag across the platter and let go: it spins at the speed of your hand, friction bleeds that off, and a spring seats it on the nearest quarter turn. Which block is in front is worked out again on every frame.
import { HairlineTurntable } from "@oration/canon/components/hairline";import { SliderField } from "@oration/canon/components/slider-field";import * as React from "react";export function Turntable() { const [coast, setCoast] = React.useState(650); return ( <div className="flex w-full max-w-[520px] flex-col gap-4"> <HairlineTurntable coast={coast} /> <SliderField label="Coast" value={coast} onChange={setCoast} min={200} max={1500} step={50} defaultValue={650} format={(v) => `${v} ms`} /> </div> );}Your own pages
items names the pages, front to back, and the readout names the pulled one. Here, Cedarline's suppliers with a W-9 on file.
import { HairlineRiffle } from "@oration/canon/components/hairline";import * as React from "react";export function CustomItems() { const [suppliers] = React.useState([ "Northwind Freight", "Halcyon", "Orchard Street", "Bellhaven Paper", "Tidewater Steel", ]); return ( <div className="w-full max-w-[520px]"> <HairlineRiffle items={suppliers} caption="W-9s on file" /> </div> );}Reflection
Every figure draws a faded reflection under whatever stands on the floor, on by default. reflection={false} turns it off for a busy surface or a tight grid; the camera stays put, so nothing jumps.
import { HairlineKnowledge } from "@oration/canon/components/hairline";import { Switch } from "@oration/canon/components/switch";import * as React from "react";export function Reflection() { const [reflection, setReflection] = React.useState(true); return ( <div className="flex w-full max-w-[520px] flex-col gap-4"> <HairlineKnowledge reflection={reflection} /> <Switch label="Reflection" checked={reflection} onCheckedChange={setReflection} /> </div> );}Explaining an idea
A figure earns its place beside one or two sentences that say what handling it shows, with room around it and nothing interactive pressed against it.
How a Canon screen is layered
The plane sits at the bottom, the sidebar and cards rest on it, and popovers float above everything. Each layer lifts with its own shadow, never a border.
import { Button } from "@oration/canon/components/button";import { HairlineExploded } from "@oration/canon/components/hairline";import { toast } from "@oration/canon/components/toast";export function InContext() { return ( <div className="grid w-full max-w-3xl items-center gap-6 rounded-xl bg-card p-5 shadow-border md:grid-cols-[1fr_1.25fr]"> <div className="flex flex-col items-start gap-2"> <h4 className="text-sm font-semibold text-foreground"> How a Canon screen is layered </h4> <p className="text-13 text-pretty text-muted-foreground"> The plane sits at the bottom, the sidebar and cards rest on it, and popovers float above everything. Each layer lifts with its own shadow, never a border. </p> <Button type="button" variant="outline" size="sm" className="mt-1" onClick={() => toast.add({ title: "Opening Elevation" })} > Read about elevation </Button> </div> <HairlineExploded /> </div> );}States#
| State | Treatment |
|---|---|
| Rest | The drawn pose. The readout says Rest, or is empty for Exploded, which only names a picked layer. |
| Active | The pointer is over it or the arrow keys moved it. One outline turns Ink 80 over about 100ms and the readout names what is active. |
| Focus visible | Keyboard focus draws Canon's 3px ring at 50% around the stage. Slow treats focus like hover and slows its clock. |
| Settling | Springs run until they land. The figure keeps its place in the shared frame loop only while something in it still moves. |
| Offscreen | Not painted and not ticking. An IntersectionObserver takes the figure out of the frame loop until it scrolls back into view. |
| Reduced motion | Springs land at once and nothing plays on its own: Phosphor holds a still frame (Loop paused), Slow's belt stops (Paused) and Turntable seats without coasting. Pointer and keys still work. |
| Dark theme | Ink is currentColor and the ground mixes --muted with --card, so the figure inverts with no extra class. |
Behavior#
- Every figure on a page shares one
requestAnimationFrameloop. It starts on input and stops as soon as every visible figure has settled; Phosphor's loop and Slow's belt keep it running only while they're on screen. - Each figure paints into a pool of
<path>elements and writes an attribute only when it changed, so 160 pillars cost what they draw. - Pointer positions are mapped to the 400 by 320 stage, so the figures behave the same at every width.
- Riffle. Folder-tab pages with ruled lines stand in a tray with a handle slot. Fixed vertical bands pick the page. The picked page stands up and lifts, pages in front lean forward, pages behind lean back, and each one starts
staggerms after its neighbour, counted outwards from the pointer. Leaving runs the same ripple back to rest. Hit bands in the top-right corner draws the bands. - Terrain. The pointer is cast onto the ground. Each pillar's target height falls off from that point over
radiuscells, and each pillar chases it on its own spring; pillars outside the reach sink to the floor. The nearest pillar takes the bright edge. When the pointer leaves, every pillar springs back to the rest pose: low, with one rise on the lower-left side. - Exploded. Four screen sections, each with its own footprint: the plane with its lip, a sidebar, the content area with its window dots, and a popover with a field. Across the stage sets the gap, from 2 units to
maxGap; the stage height is split into four bands to pick a layer. Each corner's guide drops to the highest layer under it, or to the plane. - Phosphor. The tile floats over a base slab, tied to it by dashed corner guides, and the slab's reflection fades out below it. A 3:2 beam traces the matrix until the pointer lands on the tile. Then the pointer paints, filling the dots between samples so fast strokes don't skip. Lit dots fade over
afterglowms, and the loop returns 0.9 seconds after the pointer leaves the tile. - Slow. The belt has two ends. A crate grows in at the near end and shrinks away at the far end, so nothing slides in from off the stage. Hover springs the clock to
slowTo; leaving springs it back to 1, and the belt never stops. The crate under the pointer lifts 5 units on aliftspring and takes the bright edge; its lid's nine dots are its code, and its number goes to the readout. A crate drifts out from under a still pointer, and drops back as it goes. - Turntable. Dragging turns it under the hand. On release it coasts, friction decays its speed with a time constant of
coastms, and below 50° a second a spring seats it on the nearest quarter turn, carrying the speed it had. Moving up and down tilts the camera between 14° and 50°. The painter's order is worked out again on every frame. - Every figure draws a reflection under whatever stands on the floor (a tray, a plinth, a pad, a cabinet): its near walls mirrored at Ink 15, fading out over their own height.
reflection={false}turns it off; the camera stays where it is, so the figure doesn't jump. - The concept figures each answer the pointer in their own way: a press, a drag, a scrub, a hover. The table under Concept figures lists every one, with its keys and its one prop.
- Props are read every frame, so moving a slider changes the figure mid-gesture. Changing the number of Riffle's
itemsremounts it, and so does changingreflection.
Concept figures#
One figure for each idea in the suite, grouped by the app it lives in. Each takes the idea's own interaction, so handling it says what the thing does: you drag a ticket, press a keypad, plug in an integration.
CRM
People and the relationships between them, a campaign reaching its list, and an integration plugging in.
- 6 people
- Not connected
import { HAIRLINE_MODULES, Hairline } from "@oration/canon/components/hairline";export function Crm() { const crm = HAIRLINE_MODULES.find((module) => module.id === "crm"); return ( <ul className="grid w-full gap-3 sm:grid-cols-2 lg:grid-cols-3"> {crm?.names.map((name) => ( <li key={name}> <Hairline name={name} caption={name.charAt(0).toUpperCase() + name.slice(1)} hint={false} /> </li> ))} </ul> );}Contact Center
A recording to scrub, a thread with the other side typing, a keypad, business hours on a dial, and the widget opening from its launcher.
- Northwind Freight is typing
- 415 555
- 10:00, open
- Closed
import { HAIRLINE_MODULES, Hairline } from "@oration/canon/components/hairline";export function ContactCenter() { const contactCenter = HAIRLINE_MODULES.find( (module) => module.id === "contact-center", ); return ( <ul className="grid w-full gap-3 sm:grid-cols-2 lg:grid-cols-3"> {contactCenter?.names.map((name) => ( <li key={name}> <Hairline name={name} caption={name.charAt(0).toUpperCase() + name.slice(1)} hint={false} /> </li> ))} </ul> );}Ticketing
Tickets you drag between lanes, and a new message opening in its envelope.
- 3 open, 2 pending, 1 resolved
- 1 new message
import { HAIRLINE_MODULES, Hairline } from "@oration/canon/components/hairline";export function Ticketing() { const ticketing = HAIRLINE_MODULES.find( (module) => module.id === "tickets", ); return ( <ul className="grid w-full gap-3 sm:grid-cols-2"> {ticketing?.names.map((name) => ( <li key={name}> <Hairline name={name} caption={name.charAt(0).toUpperCase() + name.slice(1)} hint={false} /> </li> ))} </ul> );}Agents Platform
An agent at work, a flow's route, knowledge in drawers, a procedure climbed step by step, a score, skills that slot in, tools that turn, and a voice that listens.
- 5 steps
- 6 sources, 190 articles
- Step 2 of 5, verify identity
- 75 points, good
- 4 of 9 skills
import { HAIRLINE_MODULES, Hairline } from "@oration/canon/components/hairline";export function AgentsPlatform() { const agents = HAIRLINE_MODULES.find((module) => module.id === "agents"); return ( <ul className="grid w-full gap-3 sm:grid-cols-2 lg:grid-cols-3"> {agents?.names.map((name) => ( <li key={name}> <Hairline name={name} caption={name.charAt(0).toUpperCase() + name.slice(1)} hint={false} /> </li> ))} </ul> );}| Figure | App | What handling it shows | Keys | Prop |
|---|---|---|---|---|
customers | CRM | A record is the people around it. The person under the pointer steps up and the links that touch them darken. | Arrows pick | names |
campaigns | CRM | A campaign goes out in waves. Hovering launches it; each contact hops and is marked as a wave reaches it. | Focus launches | interval |
connect | CRM | An integration is a connection you make. The plug slides with the pointer and snaps into the socket near it. | → connects, ← lets go | to |
calls | Contact Center | A call is a recording you can move through. The playhead scrubs along the waveform. | ← → 10 s | duration |
conversations | Contact Center | A conversation is a thread of turns. A message lifts off the thread; the other side is typing. | Arrows pick | contact |
phone | Contact Center | A number is something you dial. Each key pressed adds a dot to the display. | Digits, Backspace, Esc | initial |
schedule | Contact Center | Business hours go round the clock. The hand follows the pointer and says whether that hour is open. | Arrows ±1 h | hours |
widget | Contact Center | The widget lives in a corner of someone else's page and opens from its launcher. | Enter toggles | defaultOpen |
tickets | Ticketing | Work moves through stages. Drag a ticket and the lane under it makes room. | ↑ ↓ pick, ← → move | lanes |
inbox | Ticketing | A new message waits to be opened. The flap swings over and the letter rises out. | Enter opens | from |
agents | Agents Platform | An agent reaches for what it needs. The ring turns and the thing nearest the front is in use. | Focus starts it | uses |
flows | Agents Platform | A flow is the route a call takes. Hover a step and a pulse runs the route to it. | Arrows pick | steps |
knowledge | Agents Platform | Knowledge is filed by source. A drawer slides out to show its cards. | Arrows pick | sources |
procedures | Agents Platform | A procedure is climbed in order. The ball hops step by step, never skipping one. | Arrows step | steps |
scorecards | Agents Platform | A score is a band. The pin stands on the ring under the pointer. | Arrows change ring | bands |
skills | Agents Platform | Skills are parts that slot together. Press a slot to drop its block in or lift it out. | Arrows move, Enter toggles | names |
tools | Agents Platform | A tool is machinery the agent runs. The pointer drives the gears, forward or in reverse. | ← → drive | rpm |
voice | Agents Platform | A voice is heard. The bars move with speech, louder the closer the pointer is. | Focus listens | gain |
The grammar#
What every figure shares. The subjects and the motion differ; these never do.
The family follows Lucas Marques's Hairline study of the hover drawings on linear.app. What carries over is the grammar, not the drawings: one projection, one stroke, rounded solids with a single crease, and a rest pose drawn on purpose. The polish is mostly restraint.
| Part | Value | Why |
|---|---|---|
| Projection | Axonometric, 45° azimuth, 30° elevation | One camera for every figure, so the set reads as one. Only Turntable lets the reader move it, and it seats back on a quarter turn. |
| Stroke | 1px, round caps and joins, non-scaling-stroke | One weight at every size. Brightness changes; weight never does. |
| Silhouette | Projected hull, corners rounded by 3 units, Ink 45 | A rounded outline reads as a made object rather than a wireframe. |
| Crease | One line, Ink 25: where the cap folds into its sides | Enough to show volume. The vertical edge between two sides is never drawn. |
| Marks | Ink 15 for bars and slats, Ink 25 for outlined regions | Stand-ins for content. No words, ever. |
| Faces | Filled with --hl-ground, the stage's own colour | Solids hide what's behind them without looking filled. |
| Bright edge | Ink 80, eased in over about 100ms | The one stroke that changes colour. One at a time. |
| Reflection | Ink 15, fading to nothing over its own height. On by default | Only under what stands on the floor (a tray, a plinth, a pad, a cabinet), to seat it. Never under a floating part. reflection={false} turns it off. |
| Stage | 400 by 320, 5:4, 12px corners | Fills its container's width. Chrome lives in the corners, outside the drawing. |
Motion#
Springs, set by a duration and a bounce, with unit mass and a damping ratio of 1 − bounce. A spring can be retargeted mid-flight, which a timed ease can't.
| Spring | Duration | Bounce | Moves |
|---|---|---|---|
lift | 360 ms | 0.12 | Riffle's cards, Exploded's gap. |
field | 480 ms | 0.14 | Terrain's pillars, each chasing a target that moves every frame. |
clock | 600 ms | 0 | Slow's clock rate. Long and flat, so time eases rather than lurches. |
seat | 560 ms | 0.2 | Turntable seating on a quarter turn, starting at the speed the hand left it. |
tilt | 420 ms | 0 | Turntable's camera elevation. |
These run longer than Canon's interface springs on purpose: a figure is something to watch, not a control to get past. Everything else follows the system. Only transforms of the drawing and ink opacity change, nothing plays on its own under reduced motion, and the frame loop sleeps the moment every figure on screen has landed.
Hover figures never pause the world. Slow eases its clock to a fraction; Phosphor's loop gives way to painting and returns. A figure that stops dead under the pointer reads as a bug.
Building a figure#
Add a figure only when an idea needs to be handled to be understood. The stage and the drawing kit do the rest.
import { HAIRLINE_MODULES, Hairline, HairlineExploded, HairlineKnowledge,} from "@oration/canon/components/hairline";// Any figure, study or concept, with its default settings<Hairline name="turntable" /><Hairline name="tickets" />// A figure with its one setting, and names of your own<HairlineExploded maxGap={32} layers={["Plane", "Sidebar", "Cards", "Dialog"]} />// Without the reflection, on a busy surface<HairlineKnowledge reflection={false} />// Every concept for one appHAIRLINE_MODULES.find((module) => module.id === "agents")?.names; // ["agents", "flows", …]Three blocks
A new figure from the kit: a plinth, three blocks on lift springs, fixed targets worked out once, and one bright edge. Everything about the stage, the frame loop and reduced motion comes from HairlineStage.
import { approach, box, fitCamera, HAIRLINE_SPRINGS, type HairlineFactory, HairlineStage, INK, lerp, makeView, project, pushSolid, type Shape, Spring, solid, type Vec3,} from "@oration/canon/components/hairline";import { SliderField } from "@oration/canon/components/slider-field";import * as React from "react";export function CustomFigure() { const [height, setHeight] = React.useState(26); const params = React.useMemo(() => ({ height }), [height]); const [factory] = React.useState(() => { const blocks: HairlineFactory<{ height: number }> = (ctx) => { const fit: Vec3[] = []; for (const x of [-46, 46]) for (const y of [-18, 18]) for (const z of [-6, 40]) fit.push([x, y, z]); const view = makeView(fitCamera(fit, 45, 30, [60, 44, 280, 216])); // Front to back, which is also left to right on the stage. const xs = [30, 0, -30]; const heights = xs.map(() => new Spring(8, HAIRLINE_SPRINGS.lift)); const glow = xs.map(() => 0); // Fixed targets: where each block stands, worked out once. const centers = xs.map((x) => project(view, [x, 0, 0])[0]); let active: number | null = null; return { frame(dt) { let moving = false; heights.forEach((spring, k) => { spring.target = k === active ? ctx.params.current.height : 8; if (ctx.reduced) spring.jump(); else moving = spring.step(dt) || moving; const target = k === active ? 1 : 0; glow[k] = ctx.reduced ? target : approach(glow[k] ?? 0, target, dt); if (glow[k] !== target) moving = true; }); const shapes: Shape[] = []; pushSolid( shapes, solid(view, box(-46, -18, -6, 92, 36, 6)), ); for (let k = xs.length - 1; k >= 0; k -= 1) { const x = xs[k] ?? 0; pushSolid( shapes, solid( view, box( x - 9, -9, 0, 18, 18, heights[k]?.value ?? 8, ), ), lerp(INK.outline, INK.bright, glow[k] ?? 0), ); } ctx.paint(shapes); ctx.readout( active === null ? "Rest" : `Block ${active + 1}`, ); return moving; }, move([x]) { active = null; for (let k = 0; k < centers.length; k += 1) { const distance = Math.abs(x - (centers[k] ?? 0)); const best = active === null ? 30 : Math.abs(x - (centers[active] ?? 0)); if (distance < best) active = k; } }, leave() { active = null; }, }; }; return blocks; }); return ( <div className="flex w-full max-w-[520px] flex-col gap-4"> <HairlineStage name="blocks" factory={factory} params={params} description="Three blocks on a plinth. The one under the pointer rises." hintText="Hover a block" initialReadout="Rest" /> <SliderField label="Lift to" value={height} onChange={setHeight} min={12} max={40} defaultValue={26} format={(v) => `${v} units`} /> </div> );}import { type HairlineFactory, box, pushSolid, solid,} from "@oration/canon/components/hairline";const figure: HairlineFactory<{ height: number }> = (ctx) => { // Set up once: camera, springs, fixed hit targets. return { frame(dt) { // 1. Step springs (or jump them when ctx.reduced). // 2. Build shapes back to front and ctx.paint(shapes). // 3. ctx.readout("…") and return true while anything still moves. return false; }, move([x, y]) {}, // stage units, 400 by 320 leave() {}, key(key) { return false; }, };};- Set up once in the factory: a camera from
fitCamera, springs, and hit targets that don't move with the drawing. - In
frame, step every spring, orjumpit whenctx.reducedis set, and paint back to front. UsedepthSortwhen the camera turns or things pass each other. - Return true only while something still moves, so the shared loop can stop.
- Give it a drawn rest, a readout, a hint and a description. Check it in both themes, with the keyboard and with reduced motion on.
Do and don't#
Hover the belt. The clock eases to a fifth of its speed instead of stopping, so you can read each crate as it passes.
text-primary or a status color. Indigo reads as selected, and every line in the figure starts competing with the page's one filled button.Content#
- Readouts are sentence case and short: Rest, Cards, 0.20×, crate 0240, Turn 45°, tilt 30°. Rates and angles use tabular figures.
- Riffle
itemsand Explodedlayersare names someone would recognise, two words at most: Northwind Freight, Popover. - A caption names the figure, not the action: Riffle, Fig 3. The hint carries the action.
- Write
labelas a description of the picture followed by how to use it, in two short sentences.
Accessibility#
- Every figure except Phosphor is
role="group", focusable, and named by its description. Phosphor isrole="img": its loop carries the picture and painting has no keyboard equivalent. - Each concept figure has a keyboard path to what the pointer does: arrows to pick or move, Enter to open or toggle, and digits on Phone. The concept table lists them.
- The hint is linked with
aria-describedby, so a screen reader hears how to use the figure after its name. - Riffle's Hit bands toggle is a real
<button>witharia-pressed, reachable with Tab after the figure itself. - Riffle, Terrain and Exploded announce their readout politely (
aria-live="polite") as it changes. Slow and Turntable change theirs every frame, so their readout is hidden from screen readers and the description carries the meaning. - The drawing itself is
aria-hidden. Nothing inside it is text. - Under
prefers-reduced-motion: reduce, springs land at once and nothing loops on its own. Direct manipulation (dragging the turntable, painting the tile) still follows the hand. - Contrast comes from the ink steps of
currentColor. Don't fade the figure with opacity; the creases are already the faintest line Canon draws.
| Keys | Action |
|---|---|
| Tab | Focuses the figure. Slow slows its clock while focused. |
| ←→ | Riffle pulls the previous or next page. Terrain moves the rise along a row. Exploded closes or opens the gap. Turntable turns a quarter. |
| ↑↓ | Terrain moves the rise along a column. Exploded picks the layer above or below. Turntable tilts the view. |
| HomeEnd | Riffle pulls the first or last page. |
| Esc | Returns the figure to rest. |
Design tokens#
| Token | Used for |
|---|---|
currentColor (text-foreground) | Every line: Ink 15 marks, Ink 25 creases and guides, Ink 45 silhouettes, Ink 80 the bright edge |
--hl-ground | The stage and every face. color-mix(in oklch, var(--muted) 70%, var(--card)), an opaque Well Gray tint |
--ring | The 3px focus ring at 50% |
text-muted-foreground, text-foreground | Caption and hint; readout |
1px, non-scaling-stroke | The one stroke weight, at every rendered size |
API reference#
Hairline
Any figure by name, with its default settings. Also exported: HAIRLINE_NAMES (all 24), HAIRLINE_STUDIES (the six studies), HAIRLINE_MODULES (the concept figures grouped by app, each with an id, a label and its names) and the HairlineName type.
| Prop | Type | Default | Description |
|---|---|---|---|
nameRequired | HairlineName | No default | Which figure to draw: a study (riffle, terrain, exploded, phosphor, slow, turntable) or a concept (customers, campaigns, connect, calls, conversations, phone, schedule, widget, tickets, inbox, agents, flows, knowledge, procedures, scorecards, skills, tools, voice). |
label | string | No default | Replaces the figure's own accessible description. Describe the picture and how to use it. |
caption | React.ReactNode | No default | A short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers. |
hint | boolean | true | Shows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout. |
reflection | boolean | true | Draws a faded reflection under whatever stands on the floor: a tray, a plinth, a pad. Turn it off when the figure sits on a busy surface or in a tight grid. |
className | string | No default | Merged onto the stage. The stage fills its container's width at 5:4, so size it with the container. |
HairlineRiffle
A tray of folder-tab pages, with a Hit bands toggle in its top-right corner. Also exported: RIFFLE_ITEMS, the six figure names it uses by default.
| Prop | Type | Default | Description |
|---|---|---|---|
items | string[] | RIFFLE_ITEMS | One page per item, front to back. The readout names the pulled page. Three to ten read well. |
stagger | number | 40 | Milliseconds between one page moving and the next, counted outwards from the pointer. 0 moves them together. |
showHitBands | boolean | No default | Controls whether the fixed bands that pick the page are drawn, dashed, with the active band at Ink 45. Leave it out and the corner toggle owns it. |
defaultShowHitBands | boolean | false | Whether the bands start drawn, when showHitBands is left out. |
onShowHitBandsChange | (show: boolean) => void | No default | Called with the next value when the corner toggle is pressed. |
label | string | No default | Replaces the figure's own accessible description. Describe the picture and how to use it. |
caption | React.ReactNode | No default | A short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers. |
hint | boolean | true | Shows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout. |
reflection | boolean | true | Draws a faded reflection under whatever stands on the floor: a tray, a plinth, a pad. Turn it off when the figure sits on a busy surface or in a tight grid. |
className | string | No default | Merged onto the stage. The stage fills its container's width at 5:4, so size it with the container. |
HairlineTerrain
Eighty-one pillars on a plinth.
| Prop | Type | Default | Description |
|---|---|---|---|
radius | number | 3 | How far the rise reaches from the pointer, in cells. 1.5 to 5 read well. |
label | string | No default | Replaces the figure's own accessible description. Describe the picture and how to use it. |
caption | React.ReactNode | No default | A short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers. |
hint | boolean | true | Shows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout. |
reflection | boolean | true | Draws a faded reflection under whatever stands on the floor: a tray, a plinth, a pad. Turn it off when the figure sits on a busy surface or in a tight grid. |
className | string | No default | Merged onto the stage. The stage fills its container's width at 5:4, so size it with the container. |
HairlineExploded
An app window taken apart into four screen sections. Also exported: EXPLODED_LAYERS, the default names.
| Prop | Type | Default | Description |
|---|---|---|---|
layers | string[] | ["Plane", "Sidebar", "Cards", "Popover"] | Names for the four layers, bottom to top: the plane, the sidebar, the content area and the popover. Only the first four are read; a missing name falls back to the default. |
maxGap | number | 28 | The widest the gap opens, in world units, clamped to 3 to 40. The camera leaves room for 40, so the figure never zooms. |
label | string | No default | Replaces the figure's own accessible description. Describe the picture and how to use it. |
caption | React.ReactNode | No default | A short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers. |
hint | boolean | true | Shows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout. |
reflection | boolean | true | Draws a faded reflection under whatever stands on the floor: a tray, a plinth, a pad. Turn it off when the figure sits on a busy surface or in a tight grid. |
className | string | No default | Merged onto the stage. The stage fills its container's width at 5:4, so size it with the container. |
HairlinePhosphor
A seven by seven dot matrix on a tile floating over a base slab.
| Prop | Type | Default | Description |
|---|---|---|---|
afterglow | number | 520 | Milliseconds a lit dot takes to fade out. |
label | string | No default | Replaces the figure's own accessible description. Describe the picture and how to use it. |
caption | React.ReactNode | No default | A short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers. |
hint | boolean | true | Shows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout. |
reflection | boolean | true | Draws a faded reflection under whatever stands on the floor: a tray, a plinth, a pad. Turn it off when the figure sits on a busy surface or in a tight grid. |
className | string | No default | Merged onto the stage. The stage fills its container's width at 5:4, so size it with the container. |
HairlineSlow
Crates that grow in at one end of a belt, pass through a gate and shrink away at the other. The crate under the pointer lifts.
| Prop | Type | Default | Description |
|---|---|---|---|
slowTo | number | 0.2 | The clock rate while hovered or focused, as a fraction of full speed. |
label | string | No default | Replaces the figure's own accessible description. Describe the picture and how to use it. |
caption | React.ReactNode | No default | A short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers. |
hint | boolean | true | Shows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout. |
reflection | boolean | true | Draws a faded reflection under whatever stands on the floor: a tray, a plinth, a pad. Turn it off when the figure sits on a busy surface or in a tight grid. |
className | string | No default | Merged onto the stage. The stage fills its container's width at 5:4, so size it with the container. |
HairlineTurntable
Blocks on a turntable.
| Prop | Type | Default | Description |
|---|---|---|---|
coast | number | 650 | The friction's time constant in milliseconds: how long a flick keeps turning before the spring seats it. |
label | string | No default | Replaces the figure's own accessible description. Describe the picture and how to use it. |
caption | React.ReactNode | No default | A short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers. |
hint | boolean | true | Shows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout. |
reflection | boolean | true | Draws a faded reflection under whatever stands on the floor: a tray, a plinth, a pad. Turn it off when the figure sits on a busy surface or in a tight grid. |
className | string | No default | Merged onto the stage. The stage fills its container's width at 5:4, so size it with the container. |
HairlineCustomers
People on a plinth, tied by their relationships. Also exported: CUSTOMER_NAMES.
| Prop | Type | Default | Description |
|---|---|---|---|
names | string[] | CUSTOMER_NAMES | Names for the six people, read by the readout. |
label | string | No default | Replaces the figure's own accessible description. Describe the picture and how to use it. |
caption | React.ReactNode | No default | A short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers. |
hint | boolean | true | Shows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout. |
reflection | boolean | true | Draws a faded reflection under whatever stands on the floor: a tray, a plinth, a pad. Turn it off when the figure sits on a busy surface or in a tight grid. |
className | string | No default | Merged onto the stage. The stage fills its container's width at 5:4, so size it with the container. |
HairlineCampaigns
A hub that sends waves out to sixteen contacts.
| Prop | Type | Default | Description |
|---|---|---|---|
interval | number | 1400 | Milliseconds between waves while the campaign runs. |
label | string | No default | Replaces the figure's own accessible description. Describe the picture and how to use it. |
caption | React.ReactNode | No default | A short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers. |
hint | boolean | true | Shows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout. |
reflection | boolean | true | Draws a faded reflection under whatever stands on the floor: a tray, a plinth, a pad. Turn it off when the figure sits on a busy surface or in a tight grid. |
className | string | No default | Merged onto the stage. The stage fills its container's width at 5:4, so size it with the container. |
HairlineConnect
A plug on its cable that snaps into an integration's socket.
| Prop | Type | Default | Description |
|---|---|---|---|
to | string | "NetSuite" | The app being connected, named by the readout. |
label | string | No default | Replaces the figure's own accessible description. Describe the picture and how to use it. |
caption | React.ReactNode | No default | A short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers. |
hint | boolean | true | Shows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout. |
reflection | boolean | true | Draws a faded reflection under whatever stands on the floor: a tray, a plinth, a pad. Turn it off when the figure sits on a busy surface or in a tight grid. |
className | string | No default | Merged onto the stage. The stage fills its container's width at 5:4, so size it with the container. |
HairlineCalls
A call recording as its waveform, with a playhead to scrub.
| Prop | Type | Default | Description |
|---|---|---|---|
duration | number | 220 | The recording's length in seconds, for the readout. |
label | string | No default | Replaces the figure's own accessible description. Describe the picture and how to use it. |
caption | React.ReactNode | No default | A short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers. |
hint | boolean | true | Shows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout. |
reflection | boolean | true | Draws a faded reflection under whatever stands on the floor: a tray, a plinth, a pad. Turn it off when the figure sits on a busy surface or in a tight grid. |
className | string | No default | Merged onto the stage. The stage fills its container's width at 5:4, so size it with the container. |
HairlineConversations
A conversation as its thread, with the other side typing.
| Prop | Type | Default | Description |
|---|---|---|---|
contact | string | "Northwind Freight" | Who the agent is talking to, named by the readout. |
label | string | No default | Replaces the figure's own accessible description. Describe the picture and how to use it. |
caption | React.ReactNode | No default | A short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers. |
hint | boolean | true | Shows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout. |
reflection | boolean | true | Draws a faded reflection under whatever stands on the floor: a tray, a plinth, a pad. Turn it off when the figure sits on a busy surface or in a tight grid. |
className | string | No default | Merged onto the stage. The stage fills its container's width at 5:4, so size it with the container. |
HairlinePhone
A keypad you can press.
| Prop | Type | Default | Description |
|---|---|---|---|
initial | string | "415555" | Digits already dialled when the figure appears, so the display isn't empty. Changing it remounts the figure. |
label | string | No default | Replaces the figure's own accessible description. Describe the picture and how to use it. |
caption | React.ReactNode | No default | A short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers. |
hint | boolean | true | Shows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout. |
reflection | boolean | true | Draws a faded reflection under whatever stands on the floor: a tray, a plinth, a pad. Turn it off when the figure sits on a busy surface or in a tight grid. |
className | string | No default | Merged onto the stage. The stage fills its container's width at 5:4, so size it with the container. |
HairlineSchedule
Business hours as a dial with a hand that follows the pointer.
| Prop | Type | Default | Description |
|---|---|---|---|
hours | readonly [number, number] | [9, 17] | Opening and closing hour, 0 to 24. A close before the open runs past midnight. The pegs spring to new heights when it changes. |
label | string | No default | Replaces the figure's own accessible description. Describe the picture and how to use it. |
caption | React.ReactNode | No default | A short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers. |
hint | boolean | true | Shows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout. |
reflection | boolean | true | Draws a faded reflection under whatever stands on the floor: a tray, a plinth, a pad. Turn it off when the figure sits on a busy surface or in a tight grid. |
className | string | No default | Merged onto the stage. The stage fills its container's width at 5:4, so size it with the container. |
HairlineWidget
A web page with the chat widget's launcher in its corner.
| Prop | Type | Default | Description |
|---|---|---|---|
defaultOpen | boolean | false | Rests with the widget open, which suits a thumbnail. |
label | string | No default | Replaces the figure's own accessible description. Describe the picture and how to use it. |
caption | React.ReactNode | No default | A short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers. |
hint | boolean | true | Shows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout. |
reflection | boolean | true | Draws a faded reflection under whatever stands on the floor: a tray, a plinth, a pad. Turn it off when the figure sits on a busy surface or in a tight grid. |
className | string | No default | Merged onto the stage. The stage fills its container's width at 5:4, so size it with the container. |
HairlineTickets
A board of ticket stubs in three lanes. Also exported: TICKET_LANES.
| Prop | Type | Default | Description |
|---|---|---|---|
lanes | string[] | ["Open", "Pending", "Resolved"] | Names for the three lanes, left to right. |
label | string | No default | Replaces the figure's own accessible description. Describe the picture and how to use it. |
caption | React.ReactNode | No default | A short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers. |
hint | boolean | true | Shows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout. |
reflection | boolean | true | Draws a faded reflection under whatever stands on the floor: a tray, a plinth, a pad. Turn it off when the figure sits on a busy surface or in a tight grid. |
className | string | No default | Merged onto the stage. The stage fills its container's width at 5:4, so size it with the container. |
HairlineInbox
A new message in its envelope.
| Prop | Type | Default | Description |
|---|---|---|---|
from | string | "Halcyon" | Who the message is from, named by the readout once the letter is out. |
label | string | No default | Replaces the figure's own accessible description. Describe the picture and how to use it. |
caption | React.ReactNode | No default | A short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers. |
hint | boolean | true | Shows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout. |
reflection | boolean | true | Draws a faded reflection under whatever stands on the floor: a tray, a plinth, a pad. Turn it off when the figure sits on a busy surface or in a tight grid. |
className | string | No default | Merged onto the stage. The stage fills its container's width at 5:4, so size it with the container. |
HairlineAgents
An agent as a faceted gem with what it reaches for on a ring. Also exported: AGENT_USES.
| Prop | Type | Default | Description |
|---|---|---|---|
uses | string[] | ["Knowledge", "Tools", "Voice"] | Names for the three things on the ring: the document, the tool and the voice. |
label | string | No default | Replaces the figure's own accessible description. Describe the picture and how to use it. |
caption | React.ReactNode | No default | A short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers. |
hint | boolean | true | Shows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout. |
reflection | boolean | true | Draws a faded reflection under whatever stands on the floor: a tray, a plinth, a pad. Turn it off when the figure sits on a busy surface or in a tight grid. |
className | string | No default | Merged onto the stage. The stage fills its container's width at 5:4, so size it with the container. |
HairlineFlows
A call flow on its canvas. Also exported: FLOW_STEPS.
| Prop | Type | Default | Description |
|---|---|---|---|
steps | string[] | FLOW_STEPS | Names for the five steps: the trigger, its two branches, and what each branch leads to. |
label | string | No default | Replaces the figure's own accessible description. Describe the picture and how to use it. |
caption | React.ReactNode | No default | A short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers. |
hint | boolean | true | Shows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout. |
reflection | boolean | true | Draws a faded reflection under whatever stands on the floor: a tray, a plinth, a pad. Turn it off when the figure sits on a busy surface or in a tight grid. |
className | string | No default | Merged onto the stage. The stage fills its container's width at 5:4, so size it with the container. |
HairlineKnowledge
Knowledge as a card catalogue. Also exported: KNOWLEDGE_SOURCES.
| Prop | Type | Default | Description |
|---|---|---|---|
sources | { name: string; articles: number }[] | KNOWLEDGE_SOURCES | The six sources, one per drawer, top left to bottom right, with their article counts. |
label | string | No default | Replaces the figure's own accessible description. Describe the picture and how to use it. |
caption | React.ReactNode | No default | A short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers. |
hint | boolean | true | Shows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout. |
reflection | boolean | true | Draws a faded reflection under whatever stands on the floor: a tray, a plinth, a pad. Turn it off when the figure sits on a busy surface or in a tight grid. |
className | string | No default | Merged onto the stage. The stage fills its container's width at 5:4, so size it with the container. |
HairlineProcedures
A procedure as a staircase. Also exported: PROCEDURE_STEPS.
| Prop | Type | Default | Description |
|---|---|---|---|
steps | string[] | PROCEDURE_STEPS | Names for the five steps, bottom to top. |
label | string | No default | Replaces the figure's own accessible description. Describe the picture and how to use it. |
caption | React.ReactNode | No default | A short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers. |
hint | boolean | true | Shows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout. |
reflection | boolean | true | Draws a faded reflection under whatever stands on the floor: a tray, a plinth, a pad. Turn it off when the figure sits on a busy surface or in a tight grid. |
className | string | No default | Merged onto the stage. The stage fills its container's width at 5:4, so size it with the container. |
HairlineScorecards
A scorecard as a tiered target. Also exported: SCORE_BANDS.
| Prop | Type | Default | Description |
|---|---|---|---|
bands | string[] | ["Needs work", "Fair", "Good", "Excellent"] | Names for the four score bands, outermost ring first. |
label | string | No default | Replaces the figure's own accessible description. Describe the picture and how to use it. |
caption | React.ReactNode | No default | A short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers. |
hint | boolean | true | Shows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout. |
reflection | boolean | true | Draws a faded reflection under whatever stands on the floor: a tray, a plinth, a pad. Turn it off when the figure sits on a busy surface or in a tight grid. |
className | string | No default | Merged onto the stage. The stage fills its container's width at 5:4, so size it with the container. |
HairlineSkills
Skills as blocks in a tray of nine slots. Also exported: SKILL_NAMES.
| Prop | Type | Default | Description |
|---|---|---|---|
names | string[] | SKILL_NAMES | Names for the nine skills, top left to bottom right. |
label | string | No default | Replaces the figure's own accessible description. Describe the picture and how to use it. |
caption | React.ReactNode | No default | A short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers. |
hint | boolean | true | Shows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout. |
reflection | boolean | true | Draws a faded reflection under whatever stands on the floor: a tray, a plinth, a pad. Turn it off when the figure sits on a busy surface or in a tight grid. |
className | string | No default | Merged onto the stage. The stage fills its container's width at 5:4, so size it with the container. |
HairlineTools
A tool as a gear pair the pointer drives.
| Prop | Type | Default | Description |
|---|---|---|---|
rpm | number | 40 | The driving gear's speed at full drive, in turns a minute. |
label | string | No default | Replaces the figure's own accessible description. Describe the picture and how to use it. |
caption | React.ReactNode | No default | A short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers. |
hint | boolean | true | Shows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout. |
reflection | boolean | true | Draws a faded reflection under whatever stands on the floor: a tray, a plinth, a pad. Turn it off when the figure sits on a busy surface or in a tight grid. |
className | string | No default | Merged onto the stage. The stage fills its container's width at 5:4, so size it with the container. |
HairlineVoice
A microphone among the bars of its level.
| Prop | Type | Default | Description |
|---|---|---|---|
gain | number | 1 | How loud it hears, 0 to 1.5. The pointer's distance scales it. |
label | string | No default | Replaces the figure's own accessible description. Describe the picture and how to use it. |
caption | React.ReactNode | No default | A short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers. |
hint | boolean | true | Shows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout. |
reflection | boolean | true | Draws a faded reflection under whatever stands on the floor: a tray, a plinth, a pad. Turn it off when the figure sits on a busy surface or in a tight grid. |
className | string | No default | Merged onto the stage. The stage fills its container's width at 5:4, so size it with the container. |
HairlineStage
The frame every figure is drawn in, for building a new one. It owns visibility, reduced motion, input and the shared frame loop; the figure is a HairlineFactory that returns a controller with frame(dt) and optional move, leave, down, up, key, focus and blur. The drawing kit is exported from the same module: the camera (makeView, fitCamera, project, unproject, depthSort), solids (box and solid, prism, cylinder, sphere, plate), reflections (floorReflection, cylinderReflection), and motion (Spring, SpringSet, Glows, HAIRLINE_SPRINGS, INK). Read ctx.reflection to draw a reflection only when it's on.
Other props spread onto Nothing. Only the props below reach the stage..
| Prop | Type | Default | Description |
|---|---|---|---|
nameRequired | string | No default | Set as data-name on the stage. |
factoryRequired | HairlineFactory<P> | No default | Builds the figure's controller. Keep it stable: a new function remounts the figure. |
paramsRequired | P | No default | The figure's settings, read through ctx.params.current on every frame. |
descriptionRequired | string | No default | The accessible name, unless label replaces it. |
hintTextRequired | string | No default | What the hint corner says. |
initialReadoutRequired | string | No default | The readout before the first frame, so the server render matches. |
keyboard | boolean | false | Makes the stage focusable as role="group" and passes keys to the controller. |
live | boolean | false | Announces the readout politely. Only for readouts that change in steps. |
cursor | "default" | "grab" | "crosshair" | "default" | The pointer over the stage. |
action | { label: string; pressed: boolean; onToggle: () => void } | No default | A toggle button in the top-right corner, for a view of the figure's working such as Riffle's hit bands. It carries aria-pressed. |
label | string | No default | Replaces the figure's own accessible description. Describe the picture and how to use it. |
caption | React.ReactNode | No default | A short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers. |
hint | boolean | true | Shows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout. |
reflection | boolean | true | Draws a faded reflection under whatever stands on the floor: a tray, a plinth, a pad. Turn it off when the figure sits on a busy surface or in a tight grid. |
className | string | No default | Merged onto the stage. The stage fills its container's width at 5:4, so size it with the container. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
Experimental, and not used anywhere in apps/web yet. Adoption is zero on purpose until a page needs one.
Tickets and Skills need a press or a drag, and Phone a press, so they do less on touch than on a pointer: a tap presses, but there is no hover to preview.
Riffle, Exploded and Slow answer hover. On touch they only respond while a finger moves across them, and a vertical swipe scrolls the page instead.
Phosphor has no keyboard equivalent for painting.
The ground is an opaque mix of Well Gray and Card White, so a stage placed on the sidebar rail or a tinted well doesn't take on that surface.