← One Line · field notes
The working notes behind One Line — one SVG path element, art-directed point by point, that draws itself as you scroll and becomes every scene in the story.
One Line is a scroll-told short story about an old mapmaker drawing his coastline for the last time. The conceit: the entire illustration layer is a single, unbroken <path>. The same stroke that underlines the title becomes the roofline of his house, a coiled mooring rope, three gulls, the cliff strata, the lighthouse, a squall, and finally the flat blue horizon — where its tip comes to rest as the period of the last sentence.
The aesthetic school is ink-on-paper book illustration — continuous-line drawing (think Picasso's one-line animals, Saul Steinberg's wandering nib) crossed with literary print design: warm cream, a serif with real italics, drop cap, roman-numeral chapters, one restrained accent color for the sea.
There is no trick that generates this. The path was budgeted like a book: a tall viewBox="0 0 1000 11600", a scene ledger, then coordinates sketched by hand so each scene lives in the left band (x ≤ 500) while text sits to the right. Between scenes the line simply walks — loose S-curves down a rail near x = 200 that read as the path underfoot.
| y range | scene | drawn with |
|---|---|---|
| 348 | Title underline | the path's first segment, under the SVG <text> title |
| 1310–1690 | House | ground → wall → roof slope interrupted by a chimney → eave → wall |
| 2560–2960 | Rope coil | ten concentric semicircular arcs, tail crossing out |
| 3720–3820 | Gulls | paired cubic "m" strokes at staggered heights |
| 4670–5480 | Cliffs | orthogonal h/v ledges stepping down to a beach |
| 6500–7180 | Lighthouse | up one tapered side, gallery, lantern dome, down the other |
| 8120–8790 | Squall | cubics whose control points cross, so crests loop like breaking waves |
| 9760–11014 | Horizon & period | ripples decay into a flat run, inked sea-blue, ending at a full stop |
A hand-drawn spiral sounds hard until you notice that alternating semicircular arcs around a shared center are a spiral. Each arc's endpoints must be exactly 2r apart on the same horizontal line, and every arc keeps sweep=1 so the pen keeps circling the same way:
<!-- outer to inner: 172, 150, 128, 108, 90, 74, 60, 48, 38, 30 --> A 172 172 0 0 1 98 2790 bottom half: 442 → 98 (344 = 2×172) A 150 150 0 0 1 398 2790 top half: 98 → 398 (300 = 2×150) A 128 128 0 0 1 142 2790 bottom: 398 → 142 (256 = 2×128) … and so on, tightening toward the center
Each gull is two short cubics — a steep wing up-and-over, a cusp, and a second wing — connected to its neighbors by long shallow glides. The trick that makes them read as birds instead of waves: keep the glide sag much flatter than the wing peaks, and stagger the gulls' baselines so they occupy sky, not sea.
c 12 -30 26 -34 37 -9 wing one (sharp peak) c 11 -27 27 -23 39 3 wing two (cusp between) c 30 26 72 16 102 -30 long shallow glide to the next, higher bird
A breaking wave is a cubic whose control points cross over each other — the curve is forced to loop back through itself like a curling crest. Four cubics make one wave module: climb, curl back, sweep under the curl (this crossing draws the loop), then run along the trough.
The classic line-drawing technique: set stroke-dasharray to the path's total length so the whole line is one giant dash, then slide stroke-dashoffset from L to 0. Whatever fraction has slid past is "inked."
const L = path.getTotalLength(); path.style.strokeDasharray = L + ' ' + L; path.style.strokeDashoffset = L; // blank page // each frame: path.style.strokeDashoffset = L - drawnLength;
One landmine worth knowing: don't combine this with vector-effect: non-scaling-stroke. Chrome computes dash metrics for non-scaling strokes in screen space, so your user-unit dashoffset silently desyncs from getPointAtLength. One Line scales stroke-width in JS instead, to hold a constant ~2.5px nib at every viewport.
Mapping scroll to length linearly fails here, because the line's length is not evenly distributed down the page — the coil packs ~2,800 units of ink into 400 vertical units. So the page samples the path once, records each sample's y, and forces the sequence monotonic with a minimum slope. Binary-searching that "anchor" array turns the y the reader is looking at into how much line should exist — and dense scenes (coil, lighthouse) draw themselves in a quick flourish while the scroll passes them.
let ay = 0;
for (let i = 0; i <= N; i++){
const p = path.getPointAtLength(L * i / N);
ay = Math.max(ay + 0.5, p.y); // monotonic, min slope 0.5/sample
anchors[i] = ay; lens[i] = L * i / N;
}
// scroll → tipY → binary search anchors → lerp into lens[]
The drawn length lags its target through a frame-rate-independent lerp, so the pen accelerates and settles like a hand rather than a scrollbar. A small circle rides the tip via getPointAtLength — and because the path's final coordinate sits just after the last sentence (an SVG <text> with text-anchor="end" pinned to it), the resting nib is the story's full stop.
const k = 1 - Math.pow(0.94, dt / 16.7); // ~6%/frame at 60fps
cur += (target - cur) * k;
const p = path.getPointAtLength(cur);
nib.setAttribute('cx', p.x); nib.setAttribute('cy', p.y);
The rule said one path element — so the sea-blue ending is not a second path. It's a vertical linearGradient in userSpaceOnUse units applied to the stroke: pure ink until y ≈ 9,160, blending to sea-blue as the storm dies into the horizon.
Built by Claude (Fable 5) writing vanilla HTML, CSS and JavaScript by hand — no frameworks, no build step, no drawing software; every coordinate in the path was reasoned out on graph-paper logic and corrected against screenshots. It is one of 25 sites in the Fable Showcase, each built in a wildly different visual language. The whole set lives at fable-25-dhb.pages.dev.
Respecting prefers-reduced-motion: the line renders fully drawn, every passage is visible, and the nib rests at the period — the finished map instead of the walk.
A r r 0 0 1 arcs whose endpoints sit 2r apart on a shared baseline coil perfectly — no math beyond subtraction.non-scaling-stroke. Dashes go screen-space in Chromium and your pen tip drifts off the ink. Scale stroke-width in JS instead.getPointAtLength once and binary-searching a min-slope y-envelope lets a wandering, backtracking path still track the reader's viewport.cur += (target - cur) * k with a frame-normalized k is one line of code, but it's the difference between a scrollbar and a hand that hurries, hesitates, and catches up.