← Backgrounds

Topographic Contours

The contour lines of a map flow and shift slowly behind a section, as if the hills and valleys underneath were gently rising and falling. Every fifth line is heavier, like the index contours on a real map.

Find your way

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-topographic-contours to a section, hero or card.

Options

Add any of these attributes to the same element.

data-colorLine colour. Default sky blue #7dd3fc.
data-linesHow many contour levels. Default 16.
data-scaleSize of the hills. Default 1.
data-speedSpeed multiplier. Default 1.

It’s drawn on a transparent canvas behind your content and paused when off screen. With reduced motion the map holds still. On a light background, pick a darker line colour, e.g. data-color="#92400e".

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Topographic Contours: the contour lines of a map, flowing and shifting
    // slowly as the hills and valleys beneath them rise and fall. Every fifth
    // line is drawn heavier, like the index contours on a real map. Drawn on
    // a transparent canvas behind your content, and paused when off screen.
    // Usage: <section data-topographic-contours style="background:#0b1220">…</section>
    // Optional: data-color (line colour, default sky blue #7dd3fc),
    // data-lines (how many levels, default 16), data-scale (size of the hills,
    // default 1), data-speed (default 1).
    var DEFAULTS = { color: '#7dd3fc', lines: 16, scale: 1, speed: 1 };
    var MAX_CELLS = 9000;

    var css =
      '[data-topographic-contours]{position:relative;isolation:isolate;overflow:hidden}' +
      '.tpc-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1;pointer-events:none}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    // Smooth 3D gradient noise (the third dimension is time).
    var perm = new Uint8Array(512);
    (function () {
      var p = [], i;
      for (i = 0; i < 256; i++) p[i] = i;
      for (i = 255; i > 0; i--) { var j = (Math.random() * (i + 1)) | 0, s = p[i]; p[i] = p[j]; p[j] = s; }
      for (i = 0; i < 512; i++) perm[i] = p[i & 255];
    })();
    function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
    function grad(hash, x, y, z) {
      var h = hash & 15, u = h < 8 ? x : y, v = h < 4 ? y : (h === 12 || h === 14 ? x : z);
      return ((h & 1) ? -u : u) + ((h & 2) ? -v : v);
    }
    function noise(x, y, z) {
      var X = Math.floor(x) & 255, Y = Math.floor(y) & 255, Z = Math.floor(z) & 255;
      x -= Math.floor(x); y -= Math.floor(y); z -= Math.floor(z);
      var u = fade(x), v = fade(y), w = fade(z);
      var A = perm[X] + Y, AA = perm[A] + Z, AB = perm[A + 1] + Z;
      var B = perm[X + 1] + Y, BA = perm[B] + Z, BB = perm[B + 1] + Z;
      function lerp(t, a, b) { return a + t * (b - a); }
      return lerp(w,
        lerp(v, lerp(u, grad(perm[AA], x, y, z), grad(perm[BA], x - 1, y, z)),
                lerp(u, grad(perm[AB], x, y - 1, z), grad(perm[BB], x - 1, y - 1, z))),
        lerp(v, lerp(u, grad(perm[AA + 1], x, y, z - 1), grad(perm[BA + 1], x - 1, y, z - 1)),
                lerp(u, grad(perm[AB + 1], x, y - 1, z - 1), grad(perm[BB + 1], x - 1, y - 1, z - 1))));
    }

    // Which cell edges a contour crosses, for each of the 16 corner patterns
    // (marching squares). Edges: 0 top, 1 right, 2 bottom, 3 left.
    var SEGS = [[], [3, 2], [2, 1], [3, 1], [0, 1], [0, 1, 3, 2], [0, 2], [0, 3], [0, 3], [0, 2], [0, 3, 2, 1], [0, 1], [3, 1], [2, 1], [3, 2], []];

    function build(el) {
      var d = el.dataset;
      var color = d.color || DEFAULTS.color;
      var lines = Math.max(4, +d.lines || DEFAULTS.lines);
      var scale = +d.scale || DEFAULTS.scale;
      var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
      var canvas = document.createElement('canvas');
      canvas.className = 'tpc-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var w = 0, h = 0, dpr = 1, t = 0, g = 8, cols = 0, rows = 0, field = null;
      var LO = -0.62, HI = 0.62, gap = (HI - LO) / lines;

      function resize() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        w = el.clientWidth;
        h = el.clientHeight;
        canvas.width = w * dpr;
        canvas.height = h * dpr;
        g = Math.max(6, Math.sqrt((w * h) / MAX_CELLS));
        cols = Math.ceil(w / g) + 1;
        rows = Math.ceil(h / g) + 1;
        field = new Float32Array((cols + 1) * (rows + 1));
        draw();
      }

      function sample() {
        var k = 1 / (260 * scale), z = t * 0.06;
        for (var j = 0; j <= rows; j++) {
          for (var i = 0; i <= cols; i++) {
            var x = i * g * k + t * 0.02, y = j * g * k;
            // Two layers of noise: broad hills with smaller bumps on them.
            field[j * (cols + 1) + i] = noise(x, y, z) * 0.85 + noise(x * 2.3 + 40, y * 2.3, z * 1.6) * 0.3;
          }
        }
      }

      function draw() {
        sample();
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        ctx.strokeStyle = color;
        ctx.lineJoin = 'round';
        // Minor lines in one path and index lines in another, so it's just
        // two strokes however many segments there are.
        var minor = new Path2D(), major = new Path2D();
        var W = cols + 1;
        for (var j = 0; j < rows; j++) {
          for (var i = 0; i < cols; i++) {
            var a = field[j * W + i], b = field[j * W + i + 1];
            var c = field[(j + 1) * W + i + 1], dd = field[(j + 1) * W + i];
            var lo = Math.min(a, b, c, dd), hi = Math.max(a, b, c, dd);
            var first = Math.ceil((lo - LO) / gap), last = Math.floor((hi - LO) / gap);
            var x0 = i * g, y0 = j * g;
            for (var n = first; n <= last; n++) {
              var lv = LO + n * gap;
              var idx = (a > lv ? 8 : 0) | (b > lv ? 4 : 0) | (c > lv ? 2 : 0) | (dd > lv ? 1 : 0);
              var segs = SEGS[idx];
              var path = n % 5 === 0 ? major : minor;
              for (var s = 0; s < segs.length; s += 2) {
                var p = edge(segs[s], lv, x0, y0, a, b, c, dd), q = edge(segs[s + 1], lv, x0, y0, a, b, c, dd);
                path.moveTo(p[0], p[1]);
                path.lineTo(q[0], q[1]);
              }
            }
          }
        }
        ctx.globalAlpha = 0.32;
        ctx.lineWidth = 1;
        ctx.stroke(minor);
        ctx.globalAlpha = 0.75;
        ctx.lineWidth = 1.6;
        ctx.stroke(major);
        ctx.globalAlpha = 1;
      }

      // Where the level crosses one edge of a cell.
      function edge(e, lv, x0, y0, a, b, c, d) {
        if (e === 0) return [x0 + g * (lv - a) / (b - a), y0];
        if (e === 1) return [x0 + g, y0 + g * (lv - b) / (c - b)];
        if (e === 2) return [x0 + g * (lv - d) / (c - d), y0 + g];
        return [x0, y0 + g * (lv - a) / (d - a)];
      }

      t = 10;
      resize();
      if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;

      var last = 0;
      var timer = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          var dt = last ? Math.min(50, now - last) : 16;
          last = now;
          t += (dt / 1000) * speed;
          draw();
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-topographic-contours]').forEach(function (el) {
        if (el._tpc) return;
        el._tpc = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-topographic-contours style="background: #0b1220; color: #fff">
  <h1>Find your way</h1>
</section>