The project's README from github.com/lucasmarkes/hairline. As one markdown file: README.md.
Nineteen isometric line figures that answer the pointer. For React and for anything with a DOM.

Live, with a slider for intensity: hairline.lucasmarkes.com
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
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>.
import { terrain } from "@lucasmarkes/hairline";
const figure = terrain(document.getElementById("figure")!);
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.
| 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. |
Every figure takes the same four, 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×". |
In update, a key set to undefined goes back to its default, and a key left out stays as it is.
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.
label. Riffle is the exception: it is a focusable group, the arrow keys walk its cards, and a live region reads the card out.prefers-reduced-motion, the figures that play on their own (Phosphor and Slow) hold still, and every figure still answers the pointer.requestAnimationFrame loop. A figure off screen, or at rest, does no work, and the loop stops when nothing is moving.onMount, or in a script after the element.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 nineteen above, as one HTML file.
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.
MIT © Lucas Marques