Anatomy of Topographic Drift
How an 18px noise grid, a 16-case marching-squares switch, and a creeping z-offset redraw contour lines every frame and freeze under reduced motion.
↓Scroll to step through it
Sample the field
TopographicDrift draws on a canvas. Every frame it samples a
value-noise field onto a coarse grid, connects isolines with marching
squares, and nudges the noise's z-offset so the terrain slowly drifts.
The canvas clears and redraws; it keeps no path geometry between frames.
Corners sit on an ~18px lattice. Each corner stores one noise value:
lineCount (default 9) is the number of elevation levels you stroke
afterward, not the number of grid cells.
The dots in the diagram are those sample corners. Their shade shows noise amplitude so you can see the field before any lines are drawn.
Marching squares → isolines
For every cell and every level l / (lineCount + 1), the four corners
become a 4-bit mask (tl|tr|br|bl). Mask 0 and 15 skip (all below /
all above). Everything else picks edge midpoints by linear interpolation and
strokes one or two segments, following the classic 16-case table.
Stroke alpha peaks on mid-levels and falls off toward the extremes:
0.1 + 0.25 * (1 - Math.abs(0.5 - level) * 2)Drift is just z
The "living map" feeling is one line in the tick:
Same lifecycle as the other canvas backgrounds: ResizeObserver rebuilds the
grid, IntersectionObserver and visibilitychange pause offscreen / when the
tab hides, and prefers-reduced-motion stops the loop on a still frame.
Color comes from --foreground (or a color prop), re-probed when the
document theme mutates, so light and dark both stay legible without a baked
palette.
The result
Sample → march → nudge z, every frame. The canvas holds no mesh and morphs
no paths.
Each corner is valueNoise(i·cell·scale, j·cell·scale + z). Contour count is separate: lineCount levels get stroked later.
- Grid
- cell ≈ 18px sample spacing
- Field
- noiseScale = 0.004
- Levels
- lineCount default 9
field[j * stride + i] = valueNoise(i * cell * noiseScale, // default noiseScale = 0.004j * cell * noiseScale + z,);Anatomy of Topographic Drift
How an 18px noise grid, a 16-case marching-squares switch, and a creeping z-offset redraw contour lines every frame and freeze under reduced motion.
Each corner is valueNoise(i·cell·scale, j·cell·scale + z). Contour count is separate: lineCount levels get stroked later.
- Grid
- cell ≈ 18px sample spacing
- Field
- noiseScale = 0.004
- Levels
- lineCount default 9
field[j * stride + i] = valueNoise(i * cell * noiseScale, // default noiseScale = 0.004j * cell * noiseScale + z,);Sample the field
TopographicDrift draws on a canvas. Every frame it samples a
value-noise field onto a coarse grid, connects isolines with marching
squares, and nudges the noise's z-offset so the terrain slowly drifts.
The canvas clears and redraws; it keeps no path geometry between frames.
Corners sit on an ~18px lattice. Each corner stores one noise value:
lineCount (default 9) is the number of elevation levels you stroke
afterward, not the number of grid cells.
The dots in the diagram are those sample corners. Their shade shows noise amplitude so you can see the field before any lines are drawn.
Filled corners are above the level. The segment is the isoline, interpolated where the field crosses level on each edge.
- Cases
- 16 configs · skip 0 and 15
- Alpha
- 0.1 + 0.25×(1−|0.5−level|×2)
let id = 0;if (tl > level) id |= 8;if (tr > level) id |= 4;if (br > level) id |= 2;if (bl > level) id |= 1;if (id === 0 || id === 15) continue;const top = x + cell * ((level - tl) / (tr - tl || 1e-6));// …right, bottom, left — then switch (id) { case 1: seg(…); … }Marching squares → isolines
For every cell and every level l / (lineCount + 1), the four corners
become a 4-bit mask (tl|tr|br|bl). Mask 0 and 15 skip (all below /
all above). Everything else picks edge midpoints by linear interpolation and
strokes one or two segments, following the classic 16-case table.
Stroke alpha peaks on mid-levels and falls off toward the extremes:
0.1 + 0.25 * (1 - Math.abs(0.5 - level) * 2)There is no path morphing. The tick advances z, sampleField() runs again, and marching squares redraw. Offscreen / hidden / reduced motion stop the loop.
- z-offset
- z += 0.0015 × speed
- Lifecycle
- IO · visibility · reduced freeze
const tick = () => {z += 0.0015 * speed;draw(); // sampleField() + marching squares againrafId = requestAnimationFrame(tick);};Drift is just z
The "living map" feeling is one line in the tick:
Same lifecycle as the other canvas backgrounds: ResizeObserver rebuilds the
grid, IntersectionObserver and visibilitychange pause offscreen / when the
tab hides, and prefers-reduced-motion stops the loop on a still frame.
Color comes from --foreground (or a color prop), re-probed when the
document theme mutates, so light and dark both stay legible without a baked
palette.
The result
Sample → march → nudge z, every frame. The canvas holds no mesh and morphs
no paths.
Motion Score
canvas paint2D canvas marching-squares each frame