hairline
Thirty-three isometric line figures that answer the pointer. For React and for anything with a DOM.

Live, with a slider for intensity: hairline.lucasmarkes.com
Install
npm i @lucasmarkes/hairline
No dependencies. ESM only. React 18 or later is an optional peer, needed only by @lucasmarkes/hairline/react.
With shadcn, which adds the package and a wrapper that reads your theme's tokens:
npx shadcn@latest add https://hairline.lucasmarkes.com/r/hairline.json
Use
React
import { Terrain } from "@lucasmarkes/hairline/react"; export function Hero() { return <Terrain />; }
The figure fills its parent's width at a 5:4 aspect ratio. The entry is a client module, so a Server Component can render it with no "use client" of its own. Every component takes the options below and every <div> attribute, and forwards its ref to the <div>.
Anything else
import { terrain } from "@lucasmarkes/hairline"; const figure = terrain(document.getElementById("figure")!, { play: true }); figure.update({ intensity: 0.8 }); figure.destroy();
A figure draws into the element you give it, at the element's width and a 5:4 aspect ratio. update changes options on the running figure; destroy removes what the figure added. That is the shape of a Svelte action, so use:terrain={{ intensity }} works as it is.
The figures
| Function | Component | What it is | A stronger intensity |
|---|---|---|---|
riffle |
Riffle |
A tray of eight cards. The card under the pointer stands up; the arrow keys walk the cards. | The ripple spreads further from the pulled card. |
terrain |
Terrain |
Eighty-one pillars on a plinth that rise around the pointer. | A wider area rises. |
exploded |
Exploded |
An app window in four layers. Moving across opens the gap; moving down picks a layer. | The layers open further. |
phosphor |
Phosphor |
A dot matrix that plays a loop, and fades like phosphor where the pointer paints it. | The trail lingers longer. |
slow |
Slow |
Crates riding a belt through a gate. Hovering slows the clock without stopping it. | Time slows down more. |
turntable |
Turntable |
Blocks on a turntable. A flick spins it; it settles on the nearest quarter turn. | The spin coasts longer. |
keyboard |
Keyboard |
A sixty-key board. The key under the pointer sinks, and its neighbours follow it down. | A wider patch of keys sinks. |
elevator |
Elevator |
Four floors beside an open shaft. The pointer's height picks a floor; the car travels there. | The car travels faster between floors. |
phone |
Phone |
A phone in layers: glass, board, battery, shell. Moving across opens the gap; moving down picks a layer. | The layers open further. |
laptop |
Laptop |
A thin laptop, open on its hinge. The pointer's height sets the lid; it follows on a spring. | The lid opens wider. |
terminal |
Terminal |
A terminal window with its history in rows. The pointer's height scrolls back; the line under it lifts and its neighbours follow. | The lift spreads further. |
cabinet |
Cabinet |
A rack of twelve blades, a few half out. The pointer's height pulls the nearest ones out, the farther the less. | More blades come out. |
branches |
Branches |
A commit graph with a branch forking off main and merging back. The commit under the pointer rises, and its history rises after it. | More of the history rises. |
vault |
Vault |
A vault door with a dial and three bolts. The pointer turns the dial; detents catch every ten, and on the combination the bolts draw back. | The dial coasts longer. |
lockers |
Lockers |
A bank of twelve lockers, one ajar at rest. The locker under the pointer opens; the one at rest closes. | The door opens wider. |
padlock |
Padlock |
A padlock with its shackle in. As the pointer comes near the shackle lifts out and swings open. | The shackle swings further. |
patch |
Patch |
A patch panel of twenty-four ports with cables. The cable under the pointer lifts and its neighbours lean away. | The lean spreads further. |
dish |
Dish |
A parabolic dish on a two-axis gimbal. The pointer aims the dish; it follows on a spring. | The dish swings further. |
router |
Router |
A router with its antennas up. Each antenna leans toward the pointer, the nearest most. | The lean spreads further. |
loupe |
Loupe |
A stand loupe on a blank ruled sheet. The pointer drags it across; the rules pass enlarged under the glass, with nothing between them. | The glass magnifies more. |
sieve |
Sieve |
Three test sieves stacked over a pan. The pointer's height picks one; it rises clear of the stack, and every mesh is bare. | The gap opens further. |
rail |
Rail |
A garment rail with seven bare hangers. The pointer brushes them; each rocks away, the nearest most, and settles. | The brush reaches more hangers. |
plug |
Plug |
A wall socket, and a plug lying on the floor at the end of its cord. The pointer draws the plug up toward the socket; it stops short, and falls back. | The plug comes closer to the socket. |
query |
Query |
A question mark built as a bent bar over a loose ball. The hook turns toward the pointer, and the ball rolls after it. | The hook turns further. |
drawer |
Drawer |
A cabinet of three drawers. The pointer's height picks one; it slides out and shows two dividers with nothing between them. | The drawer opens further. |
basket |
Basket |
A wire basket under a bail handle. It tilts toward the pointer and shows its bare floor; the handle swings after it. | The basket tilts further. |
plot |
Plot |
A bar chart with seven flat tabs where the bars would stand. The pointer brushes them; each lifts a little and drops back to zero. | The tabs lift higher. |
hub |
Hub |
A hub with eight tiles around it on dashed links. The tile under the pointer rises and its link turns solid. | The tiles rise higher. |
relay |
Relay |
A hub with four branches of tiles. The path to the leaf under the pointer lights hop by hop. | Each hop waits longer. |
settle |
Settle |
Twelve tiles lie crooked round a hub. As the pointer nears it, they slide into a tree and the links draw in. | The tree forms from further away. |
format |
Format |
A file of ten crooked lines. The pointer runs the formatter down it, and every line above snaps square. | The lines start more crooked. |
rebuild |
Rebuild |
A tree of packages. The one under the pointer rises, and every package that depends on it rises after it. | The packages rise higher. |
stack |
Stack |
A call stack of five frames. The pointer's height picks one, and the frames above lift away to open it. | The frames above lift further. |
Options
Every figure takes the same five, all optional:
| Option | Type | Default | |
|---|---|---|---|
intensity |
number |
0.5 |
How strongly the figure answers the pointer, from 0 (subtle) to 1 (strong). A number outside 0…1 is clamped; anything that is not a number is 0.5. |
theme |
"auto" | "light" | "dark" |
"auto" |
"auto" follows the page: an ancestor with class dark or data-theme="dark", then the page's color-scheme. |
label |
string |
a description in English | The accessible name. In React, aria-label does the same. |
onRead |
(text: string) => void |
The figure's caption, each time it changes: "03", "gap 28.0", "rate 0.20×". |
|
play |
boolean |
false |
Walks the figure through its answer on its own, in a loop, until the pointer or focus arrives; it resumes after they leave. |
In update, a key set to undefined goes back to its default, and a key left out stays as it is.
Theme
Six custom properties, set on the figure or on anything above it:
.figures { --hairline-plate: #101014; /* the fill of every plate: the colour the figure sits on */ --hairline-hi: #fafafa; /* what is lit */ --hairline-edge: #a1a1aa; /* silhouettes */ --hairline-mid: #52525b; /* every other stroke */ --hairline-lo: #27272a; /* what recedes */ --hairline-stroke: 0.9; /* stroke width, in CSS pixels at any size */ }
--hairline-plate is the one to get right. Plates are filled, not transparent, because a plate hides what is drawn behind it; on a background that is neither white nor #08090a, set it to that background.
The figure's styles have no specificity, so any rule of yours wins without !important.
Notes
- Accessibility. A figure is an image with a description you can replace with
label. Riffle is the exception: it is a focusable group, the arrow keys walk its cards, and a live region reads the card out. - Reduced motion. With
prefers-reduced-motion, the figures that play on their own (Phosphor and Slow) hold still, a figure givenplayrests, and every figure still answers the pointer. - Performance. Every figure on a page shares one
requestAnimationFrameloop. A figure off screen, or at rest, does no work, and the loop stops when nothing is moving; a figure givenplaykeeps it running while it is on screen. - Server rendering. On the server a component is an empty box with a 5:4 aspect ratio, so nothing shifts when it draws. The functions need a DOM: call them in an effect, in
onMount, or in a script after the element. - Shadow DOM. A figure mounted inside a shadow root styles itself there.
Make your own
hairline-create is a skill for coding agents. Give it an idea and it draws a new figure to Hairline's ten rules, on the same engine as the thirty-three above, as one HTML file. Every figure it makes declares a tour, the stops a hand would visit, and the page it writes has a play button that walks it.
npx skills add lucasmarkes/hairline
Then type the command with an idea in the agent:
/hairline-create a sales funnel
It offers two or three concepts, draws the one you pick, checks it against the rules and in a browser, and hands over hairline-<name>.html: one file with no dependencies that opens from disk. It runs on any agent that reads skills: Claude Code, Cursor, Codex and others.
Four figures it drew, each beside its prompt: hairline.lucasmarkes.com/skill.
More
- hairline.lucasmarkes.com: every figure live, an inspector that writes the snippet for you, and a CDN example.
- How Hairline was made: the brief, the arguing, and every correction written down as a rule.
- The essay: how the figures are drawn, and why with lines.
- CHANGELOG.md and CONTRIBUTING.md.
License
MIT © Lucas Marques