Nineteen figures, one set of options.
Every figure takes the same four options and draws itself in SVG, with no dependencies. Install the package, paste a figure, and turn intensity up or down.
Install
Install the package, then import a figure wherever your interface runs.
i @lucasmarkes/hairlineClick the command to switch package manager. ESM only, no dependencies, 39.9 kB gzipped for all nineteen; a bundle that imports one carries one.
Quick start
Paste a figure. It fills its parent’s width at a 5:4 aspect ratio.
import { Terrain } from "@lucasmarkes/hairline/react";
export default function Page() {
return <Terrain />;
}Options
Every figure takes the same four options, all optional.
| Option | Type | Default | What it does |
|---|---|---|---|
intensity | number | 0.5 | How strongly the figure answers the pointer, from 0 (subtle) to 1 (strong). 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 | The accessible name. In React, aria-label does the same. |
onRead | (text: string) => void | | The figure's caption, each time it changes. Called once at mount with the rest caption. |
React
<Terrain
intensity?: number
theme?: "auto" | "light" | "dark"
label?: string
onRead?: (text: string) => void
{...divProps}
/>Import any of the nineteen from @lucasmarkes/hairline/react. Each renders a <div>, takes any <div> attribute and forwards its ref. The entry is a client module, so a Server Component renders it without writing "use client".
import { Terrain } from "@lucasmarkes/hairline/react";
export default function Page() {
return <Terrain />;
}Vanilla
terrain(element: HTMLElement, options?: HairlineOptions): {
update(options: HairlineOptions): void
destroy(): void
}One function per figure, named in lower case: riffle, terrain, exploded, phosphor, slow, turntable, keyboard, elevator, phone, laptop, terminal, cabinet, branches, vault, lockers, padlock, patch, dish, router. In update, a key set to undefined goes back to its default and a key left out stays as it is. destroy removes the drawing and its listeners.
import { terrain } from "@lucasmarkes/hairline";
const figure = terrain(document.getElementById("figure")!);
figure.update({ intensity: 0.8 });
figure.destroy();CDN
Without a bundler, import from esm.sh in a module script.
<div id="figure" style="width: 400px"></div>
<script type="module">
import { terrain } from "https://esm.sh/@lucasmarkes/hairline";
terrain(document.getElementById("figure"));
</script>Figures
Every figure takes intensity, from 0 to 1. Here is what it turns up.
Riffle
Higher intensity: The ripple spreads further from the pulled card.
A tray of eight cards. The card under the pointer stands up and its neighbours lean after it. The arrow keys walk the cards.
Terrain
Higher intensity: A wider area rises.
Eighty-one pillars on a plinth. They rise around the pointer and settle back into a dune with two rises.
Exploded
Higher intensity: The layers open further.
An app window taken apart into four layers. Moving across opens the gap; moving down picks a layer.
Phosphor
Higher intensity: The trail lingers longer.
A seven by seven dot matrix playing a loop. Where the pointer paints, the dots fade like phosphor.
Slow
Higher intensity: Time slows down more.
Crates riding a belt through a gate. Hovering slows the clock without stopping it.
Turntable
Higher intensity: The spin coasts longer.
Blocks on a turntable. A flick across it spins it, and it settles on the nearest quarter turn.
Keyboard
Higher intensity: A wider patch of keys sinks.
Sixty keys in a block. The key under the pointer sinks and its neighbours follow it down, less the further away.
Elevator
Higher intensity: The car travels faster between floors.
Four floors with the shaft open and the car inside. The pointer's height picks the floor; the car travels there.
Phone
Higher intensity: The layers open further.
A phone in layers: glass, board, battery, shell. Moving across opens the gap; moving down picks a layer.
Laptop
Higher intensity: The lid opens wider.
A thin laptop, open on its hinge. The pointer's height sets the lid; it follows on a spring.
Terminal
Higher intensity: The lift spreads further.
A terminal window with its history in rows. The pointer's height scrolls back; the line under it lifts and its neighbours follow.
Cabinet
Higher intensity: More blades come out.
A rack of twelve blades, a few half out. The pointer's height pulls the nearest ones out, the farther the less.
Branches
Higher intensity: More of the history rises.
A commit graph with a branch forking off main and merging back. The commit under the pointer rises, and its history rises after it.
Vault
Higher intensity: The dial coasts longer.
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.
Lockers
Higher intensity: The door opens wider.
A bank of twelve lockers, one ajar at rest. The locker under the pointer opens; the one at rest closes.
Padlock
Higher intensity: The shackle swings further.
A padlock with its shackle in. As the pointer comes near the shackle lifts out and swings open.
Patch
Higher intensity: The lean spreads further.
A patch panel of twenty-four ports with cables. The cable under the pointer lifts and its neighbours lean away.
Dish
Higher intensity: The dish swings further.
A parabolic dish on a two-axis gimbal. The pointer aims the dish; it follows on a spring.
Router
Higher intensity: The lean spreads further.
A router with its antennas up. Each antenna leans toward the pointer, the nearest most.
| Figure | Parameter | 0 | 0.5 | 1 |
|---|---|---|---|---|
| Riffle | stagger | 0 ms | 40 ms | 90 ms |
| Terrain | radius | 1.5 cells | 3 cells | 5 cells |
| Exploded | gap | 12 viewBox units | 28 viewBox units | 40 viewBox units |
| Phosphor | afterglow | 150 ms | 520 ms | 1500 ms |
| Slow | rate | 0.6× normal speed | 0.2× normal speed | 0.05× normal speed |
| Turntable | coast | 200 ms | 650 ms | 1500 ms |
| Keyboard | radius | 1 keys | 2 keys | 3.5 keys |
| Elevator | stiffness | 40 spring units | 100 spring units | 220 spring units |
| Phone | gap | 16 viewBox units | 28 viewBox units | 40 viewBox units |
| Laptop | lid | 100 degrees | 125 degrees | 150 degrees |
| Terminal | spread | 1 lines | 2 lines | 3.5 lines |
| Cabinet | reach | 1.5 blades | 3 blades | 5 blades |
| Branches | reach | 1 commits | 3 commits | 6 commits |
| Vault | coast | 250 ms | 600 ms | 1500 ms |
| Lockers | opening | 55 degrees | 90 degrees | 120 degrees |
| Padlock | swing | 45 degrees | 90 degrees | 100 degrees |
| Patch | radius | 1 ports | 2.5 ports | 5 ports |
| Dish | reach | 30 degrees | 50 degrees | 70 degrees |
| Router | spread | 0.5 antennas | 1.5 antennas | 3 antennas |
Theme
Six custom properties, set on the figure or on any ancestor. Without them a figure is light, or dark when an ancestor has class dark or data-theme="dark", or when the page’s color-scheme is dark.
| Property | Light | Role |
|---|---|---|
--hairline-plate | #ffffff | The fill of every plate. It hides what is drawn behind, so it must be the colour the figure sits on. |
--hairline-hi | #232327 | The stroke of what is lit: the card pulled, the layer picked, a dot that is on. |
--hairline-edge | #a4a4ac | Silhouettes, and dots at half strength. |
--hairline-mid | #c3c3c9 | Every other stroke. |
--hairline-lo | #e0e0e4 | What recedes: guides, and dots that are off. |
--hairline-stroke | 0.9 | The stroke width, in CSS pixels at any size. |
/* On a figure or anything above it. Without them a figure is light,
or dark when the page says so. --hairline-plate must be the colour
the figure sits on: it hides what is drawn behind each plate. */
.figures {
--hairline-plate: #ffffff;
--hairline-hi: #232327;
--hairline-edge: #a4a4ac;
--hairline-mid: #c3c3c9;
--hairline-lo: #e0e0e4;
--hairline-stroke: 0.9;
}Accessibility
- Each figure is an image with a description;
label(oraria-labelin React) replaces it. - Riffle is a focusable group: the arrow keys walk its cards and a live region reads out the card’s number.
- Under
prefers-reduced-motion, Phosphor and Slow hold still, and every figure still answers the pointer.
Anything missing? Open an issue on GitHub.