Skip to content

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.

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: knowledge above a first look at knowledge sources, flows in the tour of the flow builder, tickets in a Ticketing announcement.
  • Through Hairline with a name when 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

At rest every silhouette is Ink 45 and every crease Ink 25. When something is active, exactly one outline turns Ink 80. Nothing fills, nothing glows, and nothing takes a hue, so the change reads at once.

The Drawn Rest Rule

When the pointer leaves, every figure goes back to a pose that was composed: pages leaning in their tray, a low field with one rise on its lower-left side, screen sections a little apart, a turntable on a quarter turn. A flat or empty rest is a broken thumbnail.

The Fixed Target Rule

What the pointer is over is decided by fixed bands, by casting it onto the ground, or by where a solid stood before it lifted, never by the moving drawing. A page or crate that lifts can't slide out from under the pointer and drop its own hover.

The No Words Rule

Nothing inside the drawing is text. Names, rates and angles go to the readout in the corner, outside the figure, where they can be read and announced.

Anatomy#

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Readout. Bottom right, 12px medium tabular figures in Graphite Ink: the name, rate or angle the figure is showing.
  6. 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.

Hover or use ← →Rest
Stagger
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.

Move over the fieldRest
Radius
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.

Across opens, down picks
Max gap
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.

Afterglow
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.

Hover to slow time
Slow to
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.

Drag to turn, move to tilt
Coast
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.

Hover or use ← →Rest
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.

Hover a drawer6 sources, 190 articles
Reflection
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.

Across opens, down picks
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#

States
StateTreatment
RestThe drawn pose. The readout says Rest, or is empty for Exploded, which only names a picked layer.
ActiveThe 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 visibleKeyboard focus draws Canon's 3px ring at 50% around the stage. Slow treats focus like hover and slows its clock.
SettlingSprings run until they land. The figure keeps its place in the shared frame loop only while something in it still moves.
OffscreenNot painted and not ticking. An IntersectionObserver takes the figure out of the frame loop until it scrolls back into view.
Reduced motionSprings 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 themeInk 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 requestAnimationFrame loop. 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 stagger ms 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 radius cells, 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 afterglow ms, 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 a lift spring 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 coast ms, 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 items remounts it, and so does changing reflection.

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>    );}
Concept figures
FigureAppWhat handling it showsKeysProp
customersCRMA record is the people around it. The person under the pointer steps up and the links that touch them darken.Arrows picknames
campaignsCRMA campaign goes out in waves. Hovering launches it; each contact hops and is marked as a wave reaches it.Focus launchesinterval
connectCRMAn integration is a connection you make. The plug slides with the pointer and snaps into the socket near it.→ connects, ← lets goto
callsContact CenterA call is a recording you can move through. The playhead scrubs along the waveform.← → 10 sduration
conversationsContact CenterA conversation is a thread of turns. A message lifts off the thread; the other side is typing.Arrows pickcontact
phoneContact CenterA number is something you dial. Each key pressed adds a dot to the display.Digits, Backspace, Escinitial
scheduleContact CenterBusiness hours go round the clock. The hand follows the pointer and says whether that hour is open.Arrows ±1 hhours
widgetContact CenterThe widget lives in a corner of someone else's page and opens from its launcher.Enter togglesdefaultOpen
ticketsTicketingWork moves through stages. Drag a ticket and the lane under it makes room.↑ ↓ pick, ← → movelanes
inboxTicketingA new message waits to be opened. The flap swings over and the letter rises out.Enter opensfrom
agentsAgents PlatformAn agent reaches for what it needs. The ring turns and the thing nearest the front is in use.Focus starts ituses
flowsAgents PlatformA flow is the route a call takes. Hover a step and a pulse runs the route to it.Arrows picksteps
knowledgeAgents PlatformKnowledge is filed by source. A drawer slides out to show its cards.Arrows picksources
proceduresAgents PlatformA procedure is climbed in order. The ball hops step by step, never skipping one.Arrows stepsteps
scorecardsAgents PlatformA score is a band. The pin stands on the ring under the pointer.Arrows change ringbands
skillsAgents PlatformSkills are parts that slot together. Press a slot to drop its block in or lift it out.Arrows move, Enter togglesnames
toolsAgents PlatformA tool is machinery the agent runs. The pointer drives the gears, forward or in reverse.← → driverpm
voiceAgents PlatformA voice is heard. The bars move with speech, louder the closer the pointer is.Focus listensgain

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.

Hairline grammar
PartValueWhy
ProjectionAxonometric, 45° azimuth, 30° elevationOne 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.
Stroke1px, round caps and joins, non-scaling-strokeOne weight at every size. Brightness changes; weight never does.
SilhouetteProjected hull, corners rounded by 3 units, Ink 45A rounded outline reads as a made object rather than a wireframe.
CreaseOne line, Ink 25: where the cap folds into its sidesEnough to show volume. The vertical edge between two sides is never drawn.
MarksInk 15 for bars and slats, Ink 25 for outlined regionsStand-ins for content. No words, ever.
FacesFilled with --hl-ground, the stage's own colourSolids hide what's behind them without looking filled.
Bright edgeInk 80, eased in over about 100msThe one stroke that changes colour. One at a time.
ReflectionInk 15, fading to nothing over its own height. On by defaultOnly 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.
Stage400 by 320, 5:4, 12px cornersFills 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.

Hairline springs
SpringDurationBounceMoves
lift360 ms0.12Riffle's cards, Exploded's gap.
field480 ms0.14Terrain's pillars, each chasing a target that moves every frame.
clock600 ms0Slow's clock rate. Long and flat, so time eases rather than lurches.
seat560 ms0.2Turntable seating on a quarter turn, starting at the speed the hand left it.
tilt420 ms0Turntable'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.

Placing a figure
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.

Lift to
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>    );}
The shape of a factory
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; },  };};
  1. Set up once in the factory: a camera from fitCamera, springs, and hit targets that don't move with the drawing.
  2. In frame, step every spring, or jump it when ctx.reduced is set, and paint back to front. Use depthSort when the camera turns or things pass each other.
  3. Return true only while something still moves, so the shared loop can stop.
  4. 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.

Do. Give the figure a job: one sentence beside it that says what handling it shows.
Rest
No payment runs scheduledSchedule a run to pay approved invoices on a set day.
Don't. Use it as an empty state. It pulls the eye from the title and the one action, and it moves when nothing has happened.
Do. Leave it in ink, with one bright edge at a time.
Don't. Tint it with 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.
Drag to turn, move to tilt
Do. Give it room: at least 320px wide, with nothing interactive pressed against it.
Drag to turn, move to tilt
Don't. Squeeze it into a card or a thumbnail. The caption, hint and readout collide, and the 1px creases turn to noise.

Content#

  • Readouts are sentence case and short: Rest, Cards, 0.20×, crate 0240, Turn 45°, tilt 30°. Rates and angles use tabular figures.
  • Riffle items and Exploded layers are 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 label as 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 is role="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> with aria-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.
Keyboard interactions
KeysAction
TabFocuses 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.
HomeEndRiffle pulls the first or last page.
EscReturns the figure to rest.

Design tokens#

Design tokens
TokenUsed for
currentColor (text-foreground)Every line: Ink 15 marks, Ink 25 creases and guides, Ink 45 silhouettes, Ink 80 the bright edge
--hl-groundThe stage and every face. color-mix(in oklch, var(--muted) 70%, var(--card)), an opaque Well Gray tint
--ringThe 3px focus ring at 50%
text-muted-foreground, text-foregroundCaption and hint; readout
1px, non-scaling-strokeThe 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.

Props of Hairline
PropTypeDefaultDescription
nameRequiredHairlineNameNo defaultWhich 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).
labelstringNo defaultReplaces the figure's own accessible description. Describe the picture and how to use it.
captionReact.ReactNodeNo defaultA short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers.
hintbooleantrueShows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout.
reflectionbooleantrueDraws 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.
classNamestringNo defaultMerged 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.

Props of HairlineRiffle
PropTypeDefaultDescription
itemsstring[]RIFFLE_ITEMSOne page per item, front to back. The readout names the pulled page. Three to ten read well.
staggernumber40Milliseconds between one page moving and the next, counted outwards from the pointer. 0 moves them together.
showHitBandsbooleanNo defaultControls 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.
defaultShowHitBandsbooleanfalseWhether the bands start drawn, when showHitBands is left out.
onShowHitBandsChange(show: boolean) => voidNo defaultCalled with the next value when the corner toggle is pressed.
labelstringNo defaultReplaces the figure's own accessible description. Describe the picture and how to use it.
captionReact.ReactNodeNo defaultA short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers.
hintbooleantrueShows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout.
reflectionbooleantrueDraws 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.
classNamestringNo defaultMerged 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.

Props of HairlineTerrain
PropTypeDefaultDescription
radiusnumber3How far the rise reaches from the pointer, in cells. 1.5 to 5 read well.
labelstringNo defaultReplaces the figure's own accessible description. Describe the picture and how to use it.
captionReact.ReactNodeNo defaultA short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers.
hintbooleantrueShows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout.
reflectionbooleantrueDraws 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.
classNamestringNo defaultMerged 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.

Props of HairlineExploded
PropTypeDefaultDescription
layersstring[]["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.
maxGapnumber28The widest the gap opens, in world units, clamped to 3 to 40. The camera leaves room for 40, so the figure never zooms.
labelstringNo defaultReplaces the figure's own accessible description. Describe the picture and how to use it.
captionReact.ReactNodeNo defaultA short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers.
hintbooleantrueShows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout.
reflectionbooleantrueDraws 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.
classNamestringNo defaultMerged 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.

Props of HairlinePhosphor
PropTypeDefaultDescription
afterglownumber520Milliseconds a lit dot takes to fade out.
labelstringNo defaultReplaces the figure's own accessible description. Describe the picture and how to use it.
captionReact.ReactNodeNo defaultA short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers.
hintbooleantrueShows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout.
reflectionbooleantrueDraws 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.
classNamestringNo defaultMerged 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.

Props of HairlineSlow
PropTypeDefaultDescription
slowTonumber0.2The clock rate while hovered or focused, as a fraction of full speed.
labelstringNo defaultReplaces the figure's own accessible description. Describe the picture and how to use it.
captionReact.ReactNodeNo defaultA short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers.
hintbooleantrueShows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout.
reflectionbooleantrueDraws 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.
classNamestringNo defaultMerged onto the stage. The stage fills its container's width at 5:4, so size it with the container.

HairlineTurntable

Blocks on a turntable.

Props of HairlineTurntable
PropTypeDefaultDescription
coastnumber650The friction's time constant in milliseconds: how long a flick keeps turning before the spring seats it.
labelstringNo defaultReplaces the figure's own accessible description. Describe the picture and how to use it.
captionReact.ReactNodeNo defaultA short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers.
hintbooleantrueShows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout.
reflectionbooleantrueDraws 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.
classNamestringNo defaultMerged 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.

Props of HairlineCustomers
PropTypeDefaultDescription
namesstring[]CUSTOMER_NAMESNames for the six people, read by the readout.
labelstringNo defaultReplaces the figure's own accessible description. Describe the picture and how to use it.
captionReact.ReactNodeNo defaultA short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers.
hintbooleantrueShows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout.
reflectionbooleantrueDraws 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.
classNamestringNo defaultMerged 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.

Props of HairlineCampaigns
PropTypeDefaultDescription
intervalnumber1400Milliseconds between waves while the campaign runs.
labelstringNo defaultReplaces the figure's own accessible description. Describe the picture and how to use it.
captionReact.ReactNodeNo defaultA short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers.
hintbooleantrueShows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout.
reflectionbooleantrueDraws 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.
classNamestringNo defaultMerged 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.

Props of HairlineConnect
PropTypeDefaultDescription
tostring"NetSuite"The app being connected, named by the readout.
labelstringNo defaultReplaces the figure's own accessible description. Describe the picture and how to use it.
captionReact.ReactNodeNo defaultA short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers.
hintbooleantrueShows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout.
reflectionbooleantrueDraws 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.
classNamestringNo defaultMerged 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.

Props of HairlineCalls
PropTypeDefaultDescription
durationnumber220The recording's length in seconds, for the readout.
labelstringNo defaultReplaces the figure's own accessible description. Describe the picture and how to use it.
captionReact.ReactNodeNo defaultA short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers.
hintbooleantrueShows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout.
reflectionbooleantrueDraws 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.
classNamestringNo defaultMerged 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.

Props of HairlineConversations
PropTypeDefaultDescription
contactstring"Northwind Freight"Who the agent is talking to, named by the readout.
labelstringNo defaultReplaces the figure's own accessible description. Describe the picture and how to use it.
captionReact.ReactNodeNo defaultA short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers.
hintbooleantrueShows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout.
reflectionbooleantrueDraws 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.
classNamestringNo defaultMerged 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.

Props of HairlinePhone
PropTypeDefaultDescription
initialstring"415555"Digits already dialled when the figure appears, so the display isn't empty. Changing it remounts the figure.
labelstringNo defaultReplaces the figure's own accessible description. Describe the picture and how to use it.
captionReact.ReactNodeNo defaultA short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers.
hintbooleantrueShows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout.
reflectionbooleantrueDraws 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.
classNamestringNo defaultMerged 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.

Props of HairlineSchedule
PropTypeDefaultDescription
hoursreadonly [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.
labelstringNo defaultReplaces the figure's own accessible description. Describe the picture and how to use it.
captionReact.ReactNodeNo defaultA short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers.
hintbooleantrueShows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout.
reflectionbooleantrueDraws 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.
classNamestringNo defaultMerged 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.

Props of HairlineWidget
PropTypeDefaultDescription
defaultOpenbooleanfalseRests with the widget open, which suits a thumbnail.
labelstringNo defaultReplaces the figure's own accessible description. Describe the picture and how to use it.
captionReact.ReactNodeNo defaultA short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers.
hintbooleantrueShows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout.
reflectionbooleantrueDraws 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.
classNamestringNo defaultMerged 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.

Props of HairlineTickets
PropTypeDefaultDescription
lanesstring[]["Open", "Pending", "Resolved"]Names for the three lanes, left to right.
labelstringNo defaultReplaces the figure's own accessible description. Describe the picture and how to use it.
captionReact.ReactNodeNo defaultA short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers.
hintbooleantrueShows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout.
reflectionbooleantrueDraws 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.
classNamestringNo defaultMerged 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.

Props of HairlineInbox
PropTypeDefaultDescription
fromstring"Halcyon"Who the message is from, named by the readout once the letter is out.
labelstringNo defaultReplaces the figure's own accessible description. Describe the picture and how to use it.
captionReact.ReactNodeNo defaultA short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers.
hintbooleantrueShows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout.
reflectionbooleantrueDraws 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.
classNamestringNo defaultMerged 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.

Props of HairlineAgents
PropTypeDefaultDescription
usesstring[]["Knowledge", "Tools", "Voice"]Names for the three things on the ring: the document, the tool and the voice.
labelstringNo defaultReplaces the figure's own accessible description. Describe the picture and how to use it.
captionReact.ReactNodeNo defaultA short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers.
hintbooleantrueShows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout.
reflectionbooleantrueDraws 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.
classNamestringNo defaultMerged 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.

Props of HairlineFlows
PropTypeDefaultDescription
stepsstring[]FLOW_STEPSNames for the five steps: the trigger, its two branches, and what each branch leads to.
labelstringNo defaultReplaces the figure's own accessible description. Describe the picture and how to use it.
captionReact.ReactNodeNo defaultA short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers.
hintbooleantrueShows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout.
reflectionbooleantrueDraws 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.
classNamestringNo defaultMerged 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.

Props of HairlineKnowledge
PropTypeDefaultDescription
sources{ name: string; articles: number }[]KNOWLEDGE_SOURCESThe six sources, one per drawer, top left to bottom right, with their article counts.
labelstringNo defaultReplaces the figure's own accessible description. Describe the picture and how to use it.
captionReact.ReactNodeNo defaultA short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers.
hintbooleantrueShows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout.
reflectionbooleantrueDraws 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.
classNamestringNo defaultMerged 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.

Props of HairlineProcedures
PropTypeDefaultDescription
stepsstring[]PROCEDURE_STEPSNames for the five steps, bottom to top.
labelstringNo defaultReplaces the figure's own accessible description. Describe the picture and how to use it.
captionReact.ReactNodeNo defaultA short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers.
hintbooleantrueShows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout.
reflectionbooleantrueDraws 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.
classNamestringNo defaultMerged 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.

Props of HairlineScorecards
PropTypeDefaultDescription
bandsstring[]["Needs work", "Fair", "Good", "Excellent"]Names for the four score bands, outermost ring first.
labelstringNo defaultReplaces the figure's own accessible description. Describe the picture and how to use it.
captionReact.ReactNodeNo defaultA short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers.
hintbooleantrueShows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout.
reflectionbooleantrueDraws 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.
classNamestringNo defaultMerged 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.

Props of HairlineSkills
PropTypeDefaultDescription
namesstring[]SKILL_NAMESNames for the nine skills, top left to bottom right.
labelstringNo defaultReplaces the figure's own accessible description. Describe the picture and how to use it.
captionReact.ReactNodeNo defaultA short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers.
hintbooleantrueShows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout.
reflectionbooleantrueDraws 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.
classNamestringNo defaultMerged 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.

Props of HairlineTools
PropTypeDefaultDescription
rpmnumber40The driving gear's speed at full drive, in turns a minute.
labelstringNo defaultReplaces the figure's own accessible description. Describe the picture and how to use it.
captionReact.ReactNodeNo defaultA short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers.
hintbooleantrueShows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout.
reflectionbooleantrueDraws 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.
classNamestringNo defaultMerged 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.

Props of HairlineVoice
PropTypeDefaultDescription
gainnumber1How loud it hears, 0 to 1.5. The pointer's distance scales it.
labelstringNo defaultReplaces the figure's own accessible description. Describe the picture and how to use it.
captionReact.ReactNodeNo defaultA short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers.
hintbooleantrueShows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout.
reflectionbooleantrueDraws 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.
classNamestringNo defaultMerged 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..

Props of HairlineStage
PropTypeDefaultDescription
nameRequiredstringNo defaultSet as data-name on the stage.
factoryRequiredHairlineFactory<P>No defaultBuilds the figure's controller. Keep it stable: a new function remounts the figure.
paramsRequiredPNo defaultThe figure's settings, read through ctx.params.current on every frame.
descriptionRequiredstringNo defaultThe accessible name, unless label replaces it.
hintTextRequiredstringNo defaultWhat the hint corner says.
initialReadoutRequiredstringNo defaultThe readout before the first frame, so the server render matches.
keyboardbooleanfalseMakes the stage focusable as role="group" and passes keys to the controller.
livebooleanfalseAnnounces 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 defaultA 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.
labelstringNo defaultReplaces the figure's own accessible description. Describe the picture and how to use it.
captionReact.ReactNodeNo defaultA short caption in the top-left corner, such as Riffle or a figure number. Hidden from screen readers.
hintbooleantrueShows how to use the figure in the bottom-left corner. Turn it off in tight grids, where it would run into the readout.
reflectionbooleantrueDraws 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.
classNamestringNo defaultMerged 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.