The idea

The hero of this site is a topographic map of nowhere in particular, drawing itself one contour at a time, lowest elevation first, like a survey sheet coming off the plotter. It's the first thing anyone sees here, and it's built from three ingredients: procedural SVG paths, one GSAP timeline, and a hard rule about what happens when motion isn't welcome.

I set the constraints before writing any code, because the constraints are most of the design:

  • No libraries beyond GSAP. It was already in the budget for the site's scroll reveals, so the hero has to live inside what's loaded. No noise library, no SVG helper, nothing else.
  • No canvas. SVG strokes stay crisp at any pixel density, every line inherits currentColor so the light/dark theme toggle recolors the whole field for free, and each contour remains a DOM node I can address individually.
  • The static page must be complete. No JavaScript, no motion preference, script blocked: whatever happens, the visitor gets a finished map, not an empty hero waiting for a plugin.

That third constraint quietly decides everything else, so it gets its own section at the end.

Generating the contours

Real elevation data would be a lot of bytes for a decoration. The field is fake terrain: concentric rings, each one roughed up by layered sine noise so it wobbles like a real contour. Sample a circle at fixed angular steps, push each point in or out by a few stacked sine waves, and join the points with straight line segments:

js/main.js · one contour ring
// A circle, perturbed by three octaves of sine noise
function contourPath(cx, cy, radius, seed) {
  const SEGMENTS = 180;
  let d = '';

  for (let i = 0; i <= SEGMENTS; i++) {
    const angle = (i / SEGMENTS) * Math.PI * 2;
    const jitter =
      Math.sin(angle * 3  + seed)       * radius * 0.055 +
      Math.sin(angle * 7  + seed * 2.7) * radius * 0.028 +
      Math.sin(angle * 13 + seed * 4.1) * radius * 0.012;
    const r = radius + jitter;
    d += (i === 0 ? 'M' : 'L')
       + (cx + Math.cos(angle) * r).toFixed(1) + ' '
       + (cy + Math.sin(angle) * r).toFixed(1);
  }
  return d + ' Z';
}

Each ring gets its own seed so neighbors wobble independently, and the jitter amplitude scales with the radius, so the outer contours meander more than the ones near the summit. Straight segments are enough: at 180 per ring the polygon is indistinguishable from a curve, and the path string stays cheap to build. Desktop gets about eight elevation levels; below 768px I generate fewer rings with fewer segments, because nobody on a phone misses contour number seven.

Drawing in elevation order

DrawSVGPlugin handles the plotting. Every path starts at drawSVG: '0%' (set by JavaScript, never by CSS, for reasons section 06 makes clear), and one timeline sweeps them all to full length, staggered from the lowest elevation up so the terrain rises out of the sheet:

js/main.js · the intro timeline
gsap.registerPlugin(DrawSVGPlugin);
gsap.set('.contour', { drawSVG: '0%' }); // JS-only; CSS never hides them

const intro = gsap.timeline();
intro.to('.contour', {
  drawSVG: '100%',
  duration: 1.8,
  ease: 'power2.inOut',
  stagger: { each: 0.12, from: 'start' }, // DOM order = elevation order
});

That stagger.each: 0.12 is the entire feel of the intro. At 0.3 seconds the draw felt like waiting; at zero it was a page wipe. At 0.12 several pens are on the paper at once while the lines still clearly arrive in order: terrain rising, not a curtain lifting. The headline, subhead, and buttons join the same timeline afterward with position offsets, so the type lands while the ink still feels wet.

The ambient drift

A map that draws itself and then freezes reads like a screenshot of something better. So once the intro finishes, each contour layer picks up a very slow yoyo drift, a few pixels of transform over fourteen seconds. It sits just above the threshold of "static" without ever reaching "animated."

The tween is trivial. The part worth writing down is turning it off:

js/main.js · drift, paused when unseen
const drift = gsap.to('.contour-layer', {
  x: (i) => (i % 2 ? 9 : -9), // alternate direction per layer
  y: 6,
  duration: 14,
  ease: 'sine.inOut',
  yoyo: true,
  repeat: -1,
});

// Only run the loop while the hero is actually on screen
ScrollTrigger.create({
  trigger: '.hero',
  start: 'top bottom',
  end: 'bottom top',
  onToggle: (self) => (self.isActive ? drift.play() : drift.pause()),
});

document.addEventListener('visibilitychange', () => {
  document.hidden ? drift.pause() : drift.play();
});

The hero is one viewport tall. The moment it scrolls away, that loop is pure battery drain for zero visual benefit, same when the tab is hidden. Browsers already throttle background tabs, but throttled work is still work; paused is free. Two listeners, and the drift only runs while someone can see it.

Parallax without jank

On scroll, the contours split into three layer groups scrubbed at different speeds, which is what sells the depth. Two rules kept it smooth. First: transforms only. The scrub moves yPercent and nothing else (no top, no margin, nothing that invalidates layout), so the whole effect stays on the compositor. Second: will-change: transform appears exactly once, on the three group nodes. It's a promise that costs memory, because every promoted element gets its own layer; three groups earn it, a hundred and forty paths don't.

js/main.js · three layers, three speeds
gsap.utils.toArray('.contour-layer').forEach((layer, index) => {
  gsap.to(layer, {
    yPercent: -7 * (index + 1), // deeper layers travel further
    ease: 'none',
    scrollTrigger: {
      trigger: '.hero',
      start: 'top top',
      end: 'bottom top',
      scrub: true,
    },
  });
});

scrub: true, no smoothing value. A scrubbed hero that lags behind the scrollbar doesn't feel cinematic. It feels broken.

Reduced motion is a feature

Here's the constraint from section 01 paying off. The CSS default state of this page is the finished page: every contour at full length, headline visible, map marker pinned. Nothing is hidden in the stylesheet waiting for JavaScript to arrive. The script's only job is to animate from hidden, and only after asking permission:

js/main.js · the guard, before any tween
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;

if (reducedMotion) {
  // The stylesheet already shows the finished map. Light the marker, done.
  marker.classList.add('is-live');
  return;
}

// Only now is it safe to set drawSVG: '0%' and animate up from nothing
buildIntroTimeline();

If prefers-reduced-motion is set, the script adds the one class the static design wants (the map marker's live state) and returns before any drawSVG: '0%' is ever applied. The blinking caret on the typed coordinate label and the scroll cue stop too, through a plain CSS media query. What a reduced-motion visitor gets is not a degraded page. It's the same map, already surveyed.

The same decision quietly covers the failure modes nobody plans for: JavaScript disabled, the script blocked, the script throwing. One design rule, three fallbacks, zero extra code.

What I'd do differently

  • Generate the rings at build time. The terrain math is deterministic, so there's no reason every visitor's browser rebuilds identical path strings. A build step could ship the <path> elements static in the HTML, leave only the animation in JavaScript, and make view-source show an actual map.
  • Watch CSS scroll-driven animations. animation-timeline: scroll() could replace the entire parallax block with zero JavaScript. Safari support isn't where I need it yet; when it settles, a third of this file gets deleted.
  • Trace performance first, not last. The 1.8-second draw overlaps the headline's paint instead of blocking it. But I proved that in DevTools after building it. Next time the LCP budget goes in before the first tween does.