Design Lounge
Install

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

Open full size
1280 × 800 · 10.2 KB · 141 lines

Use it

Free to use · credit appreciated

1 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.

Brief

236 lines

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           │ │
│                                          │ 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 store min(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 .tick labels 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"> — interval role="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

RoleFamilySizeWeightLine-heightTrackingCase
BodyHanken Grotesk15px4001.50sentence
WordmarkNewsreader20px5001−0.01emsentence
Nav linksHanken Grotesk14px4001.50sentence
Eyebrow / sheet kickerHanken Grotesk11px / 10px6001+0.16emUPPERCASE
HeadlineNewsreader112px4000.94−0.035emsentence
Headline emNewsreader112px400 italic0.94−0.035emsentence
Sub-copyHanken Grotesk17px4001.550sentence
ButtonsHanken Grotesk14px60044px h0sentence
Sheet titleNewsreader26px4001.1−0.01emsentence
Sheet rowsHanken Grotesk13px400/50010sentence
Interval chipsHanken Grotesk12px50028px h0sentence
Play controlHanken Grotesk12px50036px h0sentence
Map ticksHanken Grotesk10px5001+0.08emmixed
Cairn labelHanken Grotesk11px6001+0.04emsentence

Motion

ElementTriggerPropertyFrom → ToDurationEasingNotes
Height fieldload, loophill centres + sinecontinuous60fps cap—t += dt/1000 × 3.5, dt clamped 50ms
Hill centresper framex, y±70px sin / ±50px costied to t—see field formula
Interval redrawchip clickisolevel count44 ↔ 22 ↔ 11instant—same v buffer, new step
Nav / buttonshovercolor, border, bg--ink-2 → --ink160ms--easeprimary hover bg #2d4636
Pause icon / labelclickplaying flagdrift ↔ freezeinstant—phase kept
All CSSreducedtransition-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: 3px on links and buttons; chips use outline-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 and aria-pressed.
  • Play is a real <button> with aria-pressed mirroring 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: --ink on --paper is well above 7:1; --ink-2 on --paper is ~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
Designed bySusan AcharyaKathmandu

Studied

Susan Acharya

Draw one scene and let it move a little. The words stay calm on top.

All sources