Nº 158 of 520
Topographic lines background
A marching-squares contour field drifts behind a Ridgeline Atlas hero; interval chips retile the map and Pause drift stops the hills.
Preview
Use it
Free to use · credit appreciated1 Once, in your project
npx skills add SusanAcharya/Design-Lounge 2 Then ask
Using the Design Lounge skill, build "Topographic lines background" (piece background-topographic-lines) in my stack. Match its brief; restyle it onto my product's theme.
No skill? Paste the brief instead. It carries the numbers, tokens and behaviour.
<!-- Design Lounge Nº 158 · "Topographic lines background" · www.designlounge.live -->
# Topographic lines background
> **Build brief for a coding agent.** Rebuild this piece in the reader's stack. If they haven't said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don't "improve" them.
## What it is
A full-bleed `<canvas>` topographic map for a print-survey publisher ("Ridgeline Atlas"). Contour lines are drawn with marching squares over six drifting Gaussian hills, so the field reads as a real 1:25 000 sheet that is slowly breathing. Every fifth contour is heavier (an index line). Copy and a survey card sit in a pale elliptical scrim so the lines never fight the 112px headline. The detail worth copying: changing the contour interval (10 / 20 / 40 m) only changes the number of isolevels — the same height field is re-stroked instantly, like swapping the interval on a printed sheet.
## Reference behaviour
1. Initial state: the map is already drifting. Thin olive contours fill the frame; index contours (every 5th) are thicker and darker. A radial paper scrim sits under the left-hand copy. The 20 m interval chip is pressed.
2. Hills wander slowly (tens of pixels) so ridges slide rather than pulse. A faint sine ripple is added to the field so flats are never empty.
3. The bottom-right card ("Survey sheet 14 · Cairn Valley, north face") lists contour interval, highest point **2,814 m**, and relief **1,560 – 2,814 m**.
4. Clicking **10 m / 20 m / 40 m** sets `aria-pressed` on that chip only and redraws with 44 / 22 / 11 levels. Default is 22 (20 m).
5. **Pause drift** stops the animation loop and swaps the icon to a play triangle; label becomes "Resume drift". Click again to continue from the same phase.
6. A 14px amber ring at `right:226px; top:360px` is labelled "Cairn 2,814".
7. While the tab is hidden the loop is cancelled; it resumes on visibility without a jump.
8. Rendering is capped at 60fps. Phase `t` starts at 20 so the first frame is an interesting ridge, not a blank field.
9. With `prefers-reduced-motion: reduce`, drift starts paused (`playing = false`). Interval chips still retile. Colour transitions drop to 1ms.
## Structure
```
1280 × 800
canvas (fixed, inset 0) + radial scrim + 20px neatline with map ticks
┌────────────────────────────────────────────────────────────────────────┐
│ nav 72 [glyph] Ridgeline Atlas Sheets Field notes Surveyors Print shop │
│ padding 0 44, gap 32 │
│ │
│ ALPINE SURVEY · AUTUMN 2026 EDITION (amber, 11/600 +0.16em) │
│ Every map begins with a │
│ patient line. 112px / .94 Newsreader │
│ sub 17px, max 440px │
│ [Browse the sheets] [How we survey] 44px buttons │
│ │
│ ○ Cairn 2,814 │
│ ┌ sheet 320px ──────────────┐ │
│ │ SURVEY SHEET 14 │ │
… 196 more lines. Copy or download for the full brief. Loading the source…Brief
236 linesBuild brief for a coding agent. Rebuild this piece in the reader’s stack. If they haven’t said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don’t “improve” them.
What it is
A full-bleed <canvas> topographic map for a print-survey publisher (“Ridgeline Atlas”). Contour lines are drawn with marching squares over six drifting Gaussian hills, so the field reads as a real 1:25 000 sheet that is slowly breathing. Every fifth contour is heavier (an index line). Copy and a survey card sit in a pale elliptical scrim so the lines never fight the 112px headline. The detail worth copying: changing the contour interval (10 / 20 / 40 m) only changes the number of isolevels — the same height field is re-stroked instantly, like swapping the interval on a printed sheet.
Reference behaviour
- Initial state: the map is already drifting. Thin olive contours fill the frame; index contours (every 5th) are thicker and darker. A radial paper scrim sits under the left-hand copy. The 20 m interval chip is pressed.
- Hills wander slowly (tens of pixels) so ridges slide rather than pulse. A faint sine ripple is added to the field so flats are never empty.
- The bottom-right card (“Survey sheet 14 · Cairn Valley, north face”) lists contour interval, highest point 2,814 m, and relief 1,560 – 2,814 m.
- Clicking 10 m / 20 m / 40 m sets
aria-pressedon that chip only and redraws with 44 / 22 / 11 levels. Default is 22 (20 m). - Pause drift stops the animation loop and swaps the icon to a play triangle; label becomes “Resume drift”. Click again to continue from the same phase.
- A 14px amber ring at
right:226px; top:360pxis labelled “Cairn 2,814”. - While the tab is hidden the loop is cancelled; it resumes on visibility without a jump.
- Rendering is capped at 60fps. Phase
tstarts at 20 so the first frame is an interesting ridge, not a blank field. - With
prefers-reduced-motion: reduce, drift starts paused (playing = false). Interval chips still retile. Colour transitions drop to 1ms.
Structure
1280 × 800
canvas (fixed, inset 0) + radial scrim + 20px neatline with map ticks
┌────────────────────────────────────────────────────────────────────────┐
│ nav 72 [glyph] Ridgeline Atlas Sheets Field notes Surveyors Print shop │
│ padding 0 44, gap 32 │
│ │
│ ALPINE SURVEY · AUTUMN 2026 EDITION (amber, 11/600 +0.16em) │
│ Every map begins with a │
│ patient line. 112px / .94 Newsreader │
│ sub 17px, max 440px │
│ [Browse the sheets] [How we survey] 44px buttons │
│ │
│ ○ Cairn 2,814 │
│ ┌ sheet 320px ──────────────┐ │
│ │ SURVEY SHEET 14 │ │
│ │ Cairn Valley, north face │ │
│ │ Contour [10m][20m][40m] │ │
│ │ Highest 2,814 m │ │
│ │ Relief 1,560 – 2,814 m │ │
│ │ [ Pause drift ] │ │
│ └ right 64, bottom 64 ──────┘ │
└────────────────────────────────────────────────────────────────────────┘
neatline inset 20px; ticks sit on the rule: 46°34′N / 8°11′E / 46°29′N /
SHEET 14 · 1:25 000 / rotated 8°02′E on the left edge
<canvas id="c" aria-hidden="true">— fixed, full viewport, backing storemin(devicePixelRatio, 2)..scrim— fixed,pointer-events:none, radial ellipse 620×380 at 34% 50%, paper at 92% opacity in the core to 0 at the edge..neat— 20px inset hairline (--line-2) with five.ticklabels on paper chips..page— relative flex column, padding 20px, height 100%.<nav aria-label="Main">— 72px: 26px ridge SVG + “Ridgeline Atlas” (Newsreader 20/500), four links.<main class="hero">— padding 76px 44px 0, max-width 780px: eyebrow,<h1>with italic<em>patient</em>, sub, two.btn..mark— decorative cairn pip,aria-hidden.<aside class="sheet" aria-label="Survey sheet">— intervalrole="group", three<button aria-pressed>, play<button id="play">.
Tokens
:root {
/* colour — warm map paper, forest ink, one ochre accent */
--paper: #e8e9dd; /* page + tick chips */
--paper-2: #f3f3ea; /* sheet, secondary button, cairn fill */
--ink: #1f3326; /* headline, primary button, contours source */
--ink-2: #4a5b4e; /* sub, nav links, ticks, row labels */
--ink-3: #6c7a6e; /* sheet kicker */
--line: rgba(31, 51, 38, .18); /* sheet row rules */
--line-2: rgba(31, 51, 38, .32); /* neatline, buttons, sheet border */
--accent: #b8661a; /* eyebrow, cairn, brand inner ridge */
--accent-ink: #fff8ee; /* unused on type; keep for SVG fills if needed */
--contour-thin: rgba(31, 51, 38, .26);
--contour-index: rgba(31, 51, 38, .5);
/* type */
--serif: "Newsreader", Georgia, serif;
--sans: "Hanken Grotesk", system-ui, sans-serif;
/* layout */
--inset: 20px;
--r: 4px;
--nav-h: 72px;
--sheet-w: 320px;
--cell: 10px; /* marching-squares cell */
/* field */
--levels-default: 22; /* 20 m */
--iso-span: 1.15; /* isolevel range / levels */
--index-every: 5; /* heavier stroke */
/* motion */
--t-fast: 160ms;
--ease: cubic-bezier(.2, .7, .2, 1);
--drift: 3.5; /* phase units per second */
}
Typography
| Role | Family | Size | Weight | Line-height | Tracking | Case |
|---|---|---|---|---|---|---|
| Body | Hanken Grotesk | 15px | 400 | 1.5 | 0 | sentence |
| Wordmark | Newsreader | 20px | 500 | 1 | −0.01em | sentence |
| Nav links | Hanken Grotesk | 14px | 400 | 1.5 | 0 | sentence |
| Eyebrow / sheet kicker | Hanken Grotesk | 11px / 10px | 600 | 1 | +0.16em | UPPERCASE |
| Headline | Newsreader | 112px | 400 | 0.94 | −0.035em | sentence |
| Headline em | Newsreader | 112px | 400 italic | 0.94 | −0.035em | sentence |
| Sub-copy | Hanken Grotesk | 17px | 400 | 1.55 | 0 | sentence |
| Buttons | Hanken Grotesk | 14px | 600 | 44px h | 0 | sentence |
| Sheet title | Newsreader | 26px | 400 | 1.1 | −0.01em | sentence |
| Sheet rows | Hanken Grotesk | 13px | 400/500 | 1 | 0 | sentence |
| Interval chips | Hanken Grotesk | 12px | 500 | 28px h | 0 | sentence |
| Play control | Hanken Grotesk | 12px | 500 | 36px h | 0 | sentence |
| Map ticks | Hanken Grotesk | 10px | 500 | 1 | +0.08em | mixed |
| Cairn label | Hanken Grotesk | 11px | 600 | 1 | +0.04em | sentence |
Motion
| Element | Trigger | Property | From → To | Duration | Easing | Notes |
|---|---|---|---|---|---|---|
| Height field | load, loop | hill centres + sine | continuous | 60fps cap | — | t += dt/1000 × 3.5, dt clamped 50ms |
| Hill centres | per frame | x, y | ±70px sin / ±50px cos | tied to t | — | see field formula |
| Interval redraw | chip click | isolevel count | 44 ↔ 22 ↔ 11 | instant | — | same v buffer, new step |
| Nav / buttons | hover | color, border, bg | --ink-2 → --ink | 160ms | --ease | primary hover bg #2d4636 |
| Pause icon / label | click | playing flag | drift ↔ freeze | instant | — | phase kept |
| All CSS | reduced | transition-duration | → 1ms | — | — | loop never starts |
Height at grid node (x, y), time t (seconds × 3.5 from start offset 20):
hills = [
[.80, .36, 170, 1, .9, 0],
[.62, .78, 210, .85, .6, 2],
[.18, .22, 190, .7, .7, 4],
[.97, .92, 150, .6, 1.1, 1],
[.36, .62, 240, .55, .5, 3],
[.08, .88, 140, .5, .8, 5],
]
/* each: [xFrac, yFrac, sigma, amp, w, phase] */
cx = xFrac*W + 70*sin(t*.05*w + phase)
cy = yFrac*H + 50*cos(t*.04*w + phase*1.3)
k = 1 / (2 * sigma * sigma)
f = 0.06 * sin(x*.004 + y*.003 + t*.03)
+ Σ amp * exp( −((x-cx)² + (y-cy)²) * k )
Isolevels: step = 1.15 / levels. Skip L ≤ 0. Stroke thin at 0.9px --contour-thin; every 5th at 1.5px --contour-index. lineCap = round. Grid pitch 10px.
Reduced motion: playing starts false; still call resize() + draw() once. Interval chips keep working.
States
- 20 m (default): second chip
aria-pressed="true", 22 levels. - 10 m / 40 m: 44 / 11 levels; previous chip
aria-pressed="false". - Drifting: play button label “Pause drift”, pause-bars icon,
aria-pressed="false"(pressed means paused). - Paused: “Resume drift”, play-triangle path
M7 5l12 7-12 7z,aria-pressed="true". - Nav hover: colour
--ink. - Secondary button hover: border
--ink. - Primary hover: background
#2d4636. - Interval chip hover: colour
--ink. Pressed chip: background--ink, colour--paper-2. - Play hover: border and colour
--ink. - Focus-visible:
outline: 2px solid var(--accent); outline-offset: 3pxon links and buttons; chips useoutline-offset: -2px.
Accessibility
- Canvas, neatline ticks and cairn mark are
aria-hidden="true". - Nav is
<nav aria-label="Main">. Sheet is<aside aria-label="Survey sheet">. - Interval control is
role="group" aria-label="Contour interval"with three real buttons andaria-pressed. - Play is a real
<button>witharia-pressedmirroring the paused state. It satisfies pause/stop/hide for the looping field. - Tab order: brand → 4 nav links → Browse the sheets → How we survey → 10 m → 20 m → 40 m → Pause drift.
- Contrast:
--inkon--paperis well above 7:1;--ink-2on--paperis ~5.5:1. Contours sit under a 92% paper scrim behind type. - Hit targets: buttons 44px, chips 28px tall × ≥40px wide, play 36px full-width of the 320px card.
Responsive rules
- ≥ 1280: as specified.
- 1024–1279: keep the sheet at
right:32px; bottom:32px; headline 88px. - 768–1023: headline 64px; sheet width 280px; hide the cairn mark if it collides with type.
- < 640: stack the sheet under the hero (static, not absolute); headline 44px; nav links wrap or hide behind the brand. Recompute the height field on resize; cell size stays 10px.
- Cap backing store at DPR 2 on every size.
Acceptance checklist
- Contours are marching-squares polylines on a 10px grid, not SVG decoration or a bitmap.
- Default interval is 20 m (22 levels); 10 m draws 44 levels and 40 m draws 11, instantly.
- Every 5th contour is 1.5px at 50% ink; others 0.9px at 26% ink.
- Drift is visible on the first frame (phase starts at 20, not 0).
- Pause freeze the field in place and relabels to “Resume drift”; Resume continues from the same phase.
- Hidden tabs cancel
requestAnimationFrame; returning does not jump. - Frame rate never exceeds 60fps; dt is clamped to 50ms.
- Reduced motion loads paused, with a fully drawn static map.
- Card copy matches: Cairn Valley, 2,814 m, relief 1,560 – 2,814 m, Sheet 14.
- Focus rings are 2px ochre on every control.
- Canvas stays sharp on retina (transform scaled by DPR, max 2).
- Neatline ticks read 46°34′N, 8°11′E, 46°29′N, SHEET 14 · 1:25 000, 8°02′E.
Implementation notes
Marching squares lookup — 16 cases as two-letter edge pairs (T top, R right, B bottom, L left). Interpolate along the edge by (L − va) / (vb − va):
const SEG = ['','LB','BR','LR','TR','TRLB','TB','TL','TL','TB','TLBR','TR','LR','RB','LB',''];
const code = (a>L)<<3 | (b>L)<<2 | (c>L)<<1 | (d>L);
const s = SEG[code]; if (!s) continue;
One Path2D for thin, one for index, stroke once per frame — do not stroke() per cell:
const thin = new Path2D(), idx = new Path2D();
// path = (k % 5 === 0) ? idx : thin
ctx.strokeStyle = 'rgba(31,51,38,.26)'; ctx.lineWidth = .9; ctx.stroke(thin);
ctx.strokeStyle = 'rgba(31,51,38,.5)'; ctx.lineWidth = 1.5; ctx.stroke(idx);
Frame cap, visibility, start offset:
let t = 20, last = 0, raf = 0, playing = !reduce;
function frame(now) {
raf = requestAnimationFrame(frame);
const dt = now - last; if (dt < 1000/60 - 1) return;
last = now; t += Math.min(dt, 50) / 1000 * 3.5; draw();
}
document.addEventListener('visibilitychange', () => document.hidden ? stop() : start());
Common mistakes: drawing contours as concentric CSS circles (they will not branch or saddle); rebuilding the canvas CSS size without setTransform(d,0,0,d,0,0) (blurry lines); starting t at 0 (a nearly empty first frame); filling the canvas instead of stroking (the piece is lines on paper, not shaded relief); putting pointer-events on the canvas so the interval chips never receive clicks (canvas is behind .page).
Details
- Palette
- Type
- Newsreader · Hanken Grotesk
- Motion
- Subtle motion
- Build
- One session
- Tags
Studied
Susan AcharyaDraw one scene and let it move a little. The words stay calm on top.
All sources