Topographic Contour Lines Background with Morphing Terrain

A dark pine hero with a living contour map behind it. A noise function describes an imaginary landscape, and a canvas draws its contour lines the way a paper map does: thin mint lines at every interval, a heavier sand line every fifth one and pale blue rings where the ground drops below the water level. The landscape morphs slowly, so ridges drift and lakes open and close. A survey crosshair follows the pointer, pushes a hill up under itself and prints the height at that spot. At rest it walks a slow loop. Copy sits on the left over a shade; on phones the shade runs from the top and the crosshair roams below the text.

Code

import { useEffect, useRef } from "react";
import { motion, useReducedMotion } from "framer-motion";

/** Distance between sample points in pixels. Smaller is smoother and slower. */
const GRID = 11;
/** Size of the hills: lower numbers give broad, lazy terrain. */
const TERRAIN_SCALE = 0.0023;
/** How fast the terrain morphs. 0 freezes it. */
const MORPH_SPEED = 0.05;
/** Height gap between two neighbouring contour lines. Every fifth line is drawn as a heavy index line. */
const INTERVAL = 0.075;
/** The hill that rises under the pointer: height and radius in pixels. */
const HILL_HEIGHT = 0.6;
const HILL_RADIUS = 150;

const COLOURS = {
  ground: [7, 24, 20],
  high: [24, 68, 52],
  water: [9, 44, 62],
  line: "rgba(167, 243, 208, 0.36)",
  index: "rgba(253, 230, 138, 0.8)",
  shore: "rgba(125, 211, 252, 0.4)",
};
/** Heights below this are drawn as water. */
const WATER_LEVEL = -0.34;

const facts = [
  { value: "1:25,000", label: "survey scale" },
  { value: "10 m", label: "contour interval" },
  { value: "Offline", label: "on every device" },
];

// A small gradient noise generator, so the terrain needs no library.
const perm = new Uint8Array(512);
{
  const p = Array.from({ length: 256 }, (_, i) => i);
  let seed = 20431;
  for (let i = 255; i > 0; i--) {
    seed = (seed * 16807) % 2147483647;
    const j = seed % (i + 1);
    [p[i], p[j]] = [p[j], p[i]];
  }
  for (let i = 0; i < 512; i++) perm[i] = p[i & 255];
}
const fade = (t: number) => t * t * t * (t * (t * 6 - 15) + 10);
const grad = (hash: number, x: number, y: number, z: number) => {
  const h = hash & 15;
  const a = h < 8 ? x : y;
  const b = h < 4 ? y : h === 12 || h === 14 ? x : z;
  return ((h & 1) === 0 ? a : -a) + ((h & 2) === 0 ? b : -b);
};
function noise(x: number, y: number, z: number): number {
  const X = Math.floor(x) & 255;
  const Y = Math.floor(y) & 255;
  const Z = Math.floor(z) & 255;
  x -= Math.floor(x);
  y -= Math.floor(y);
  z -= Math.floor(z);
  const u = fade(x);
  const v = fade(y);
  const w = fade(z);
  const A = perm[X] + Y;
  const AA = perm[A] + Z;
  const AB = perm[A + 1] + Z;
  const B = perm[X + 1] + Y;
  const BA = perm[B] + Z;
  const BB = perm[B + 1] + Z;
  const mix = (a: number, b: number, t: number) => a + (b - a) * t;
  return mix(
    mix(mix(grad(perm[AA], x, y, z), grad(perm[BA], x - 1, y, z), u), mix(grad(perm[AB], x, y - 1, z), grad(perm[BB], x - 1, y - 1, z), u), v),
    mix(mix(grad(perm[AA + 1], x, y, z - 1), grad(perm[BA + 1], x - 1, y, z - 1), u), mix(grad(perm[AB + 1], x, y - 1, z - 1), grad(perm[BB + 1], x - 1, y - 1, z - 1), u), v),
    w,
  );
}

// For each of the 16 ways a level can cut a grid square, the pairs of edges the line joins (0 top, 1 right, 2 bottom, 3 left).
const CUTS: number[][] = [[], [3, 2], [2, 1], [3, 1], [0, 1], [0, 3, 2, 1], [0, 2], [0, 3], [0, 3], [0, 2], [0, 1, 3, 2], [0, 1], [3, 1], [2, 1], [3, 2], []];

export default function TopographicContourLinesBackgroundWithMorphingTerrain() {
  const reduce = !!useReducedMotion();
  const rootRef = useRef<HTMLElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const markerRef = useRef<HTMLDivElement>(null);
  const readoutRef = useRef<HTMLSpanElement>(null);

  useEffect(() => {
    const root = rootRef.current;
    const canvas = canvasRef.current;
    const ctx = canvas?.getContext("2d");
    if (!root || !canvas || !ctx) return;

    let w = 0;
    let h = 0;
    let cols = 0;
    let rows = 0;
    let field = new Float32Array(0);
    let tint: HTMLCanvasElement | null = null;
    let tintCtx: CanvasRenderingContext2D | null = null;
    let tintData: ImageData | null = null;
    let frame = 0;
    let onScreen = true;
    let count = 0;
    const began = performance.now();
    let pointer: { x: number; y: number } | null = null;
    const hill = { x: 0, y: 0, set: false };

    const draw = (now: number) => {
      const t = ((now - began) / 1000) * MORPH_SPEED + 3;
      const s = (now - began) / 1000;
      // The survey point follows the pointer, or walks a slow loop over the open side of the map.
      const wide = w >= 768;
      const tx = pointer ? pointer.x : w * ((wide ? 0.7 : 0.5) + (wide ? 0.17 : 0.28) * Math.sin(s * 0.42 + 1));
      const ty = pointer ? pointer.y : h * ((wide ? 0.5 : 0.8) + (wide ? 0.27 : 0.1) * Math.sin(s * 0.63 + 0.2));
      if (!hill.set) {
        hill.x = tx;
        hill.y = ty;
        hill.set = true;
      }
      hill.x += (tx - hill.x) * 0.07;
      hill.y += (ty - hill.y) * 0.07;

      const k = 1 / (2 * HILL_RADIUS * HILL_RADIUS);
      for (let j = 0; j < rows; j++) {
        const y = j * GRID;
        for (let i = 0; i < cols; i++) {
          const x = i * GRID;
          const d = (x - hill.x) * (x - hill.x) + (y - hill.y) * (y - hill.y);
          field[i + j * cols] = noise(x * TERRAIN_SCALE, y * TERRAIN_SCALE, t) + 0.42 * noise(x * TERRAIN_SCALE * 2.3 + 40, y * TERRAIN_SCALE * 2.3, t * 1.6) + HILL_HEIGHT * Math.exp(-d * k);
        }
      }

      // A soft height tint under the lines: one pixel per sample, stretched by the browser.
      if (tintCtx && tintData && tint) {
        const px = tintData.data;
        for (let n = 0; n < cols * rows; n++) {
          const e = field[n];
          // Land gets lighter with height; near the water level it blends into the lake colour.
          const f = Math.min(1, Math.max(0, (e - WATER_LEVEL) / 1.1));
          const wet = Math.min(1, Math.max(0, (WATER_LEVEL + 0.05 - e) / 0.14));
          for (let c = 0; c < 3; c++) {
            const land = COLOURS.ground[c] + (COLOURS.high[c] - COLOURS.ground[c]) * f;
            px[n * 4 + c] = land + (COLOURS.water[c] - land) * wet;
          }
          px[n * 4 + 3] = 255;
        }
        tintCtx.putImageData(tintData, 0, 0);
        ctx.imageSmoothingEnabled = true;
        ctx.drawImage(tint, 0, 0, cols, rows, -GRID / 2, -GRID / 2, cols * GRID, rows * GRID);
      } else ctx.clearRect(0, 0, w, h);

      // Marching squares: for every grid square, find where each contour level crosses its edges and join the crossings.
      const thin = new Path2D();
      const heavy = new Path2D();
      const shore = new Path2D();
      const ex = [0, 0, 0, 0];
      const ey = [0, 0, 0, 0];
      for (let j = 0; j < rows - 1; j++) {
        for (let i = 0; i < cols - 1; i++) {
          const n = i + j * cols;
          const a = field[n];
          const b = field[n + 1];
          const c = field[n + 1 + cols];
          const d = field[n + cols];
          const lo = Math.min(a, b, c, d);
          const hi = Math.max(a, b, c, d);
          const x = i * GRID;
          const y = j * GRID;
          for (let level = Math.ceil(lo / INTERVAL); level * INTERVAL < hi; level++) {
            const L = level * INTERVAL;
            const kind = (a > L ? 8 : 0) | (b > L ? 4 : 0) | (c > L ? 2 : 0) | (d > L ? 1 : 0);
            const cut = CUTS[kind];
            if (!cut.length) continue;
            ex[0] = x + (GRID * (L - a)) / (b - a);
            ey[0] = y;
            ex[1] = x + GRID;
            ey[1] = y + (GRID * (L - b)) / (c - b);
            ex[2] = x + (GRID * (L - d)) / (c - d);
            ey[2] = y + GRID;
            ex[3] = x;
            ey[3] = y + (GRID * (L - a)) / (d - a);
            const path = L < WATER_LEVEL ? shore : level % 5 === 0 ? heavy : thin;
            for (let q = 0; q < cut.length; q += 2) {
              path.moveTo(ex[cut[q]], ey[cut[q]]);
              path.lineTo(ex[cut[q + 1]], ey[cut[q + 1]]);
            }
          }
        }
      }
      ctx.lineCap = "round";
      ctx.lineWidth = 1;
      ctx.strokeStyle = COLOURS.shore;
      ctx.stroke(shore);
      ctx.strokeStyle = COLOURS.line;
      ctx.stroke(thin);
      ctx.lineWidth = 1.6;
      ctx.strokeStyle = COLOURS.index;
      ctx.stroke(heavy);

      // Move the survey marker and, a few times a second, read the height under it.
      if (markerRef.current) markerRef.current.style.transform = `translate3d(${hill.x.toFixed(1)}px, ${hill.y.toFixed(1)}px, 0)`;
      if (readoutRef.current && count++ % 6 === 0) {
        const ci = Math.min(cols - 1, Math.max(0, Math.round(hill.x / GRID)));
        const cj = Math.min(rows - 1, Math.max(0, Math.round(hill.y / GRID)));
        const metres = Math.round((1480 + field[ci + cj * cols] * 760) / 10) * 10;
        readoutRef.current.textContent = `${metres.toLocaleString("en-GB")} m`;
      }
    };

    const loop = (now: number) => {
      draw(now);
      frame = requestAnimationFrame(loop);
    };
    const start = () => {
      cancelAnimationFrame(frame);
      if (reduce || !onScreen || document.hidden) return;
      frame = requestAnimationFrame(loop);
    };
    const resize = () => {
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      w = root.clientWidth;
      h = root.clientHeight;
      canvas.width = Math.round(w * dpr);
      canvas.height = Math.round(h * dpr);
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      cols = Math.ceil(w / GRID) + 2;
      rows = Math.ceil(h / GRID) + 2;
      field = new Float32Array(cols * rows);
      tint = document.createElement("canvas");
      tint.width = cols;
      tint.height = rows;
      tintCtx = tint.getContext("2d");
      tintData = tintCtx ? tintCtx.createImageData(cols, rows) : null;
      hill.set = false;
      draw(performance.now());
    };

    const move = (e: PointerEvent) => {
      const box = root.getBoundingClientRect();
      pointer = { x: e.clientX - box.left, y: e.clientY - box.top };
      if (reduce) {
        hill.set = false;
        draw(performance.now());
      }
    };
    const leave = () => (pointer = null);

    const sizes = new ResizeObserver(resize);
    sizes.observe(root);
    const view = new IntersectionObserver(([entry]) => {
      onScreen = entry.isIntersecting;
      start();
    });
    view.observe(root);
    root.addEventListener("pointermove", move);
    root.addEventListener("pointerleave", leave);
    root.addEventListener("pointercancel", leave);
    document.addEventListener("visibilitychange", start);
    resize();
    start();
    return () => {
      cancelAnimationFrame(frame);
      sizes.disconnect();
      view.disconnect();
      root.removeEventListener("pointermove", move);
      root.removeEventListener("pointerleave", leave);
      root.removeEventListener("pointercancel", leave);
      document.removeEventListener("visibilitychange", start);
    };
  }, [reduce]);

  const rise = (delay: number) => ({
    initial: reduce ? false : ({ opacity: 0, y: 18 } as const),
    animate: { opacity: 1, y: 0 },
    transition: { duration: 0.8, delay, ease: [0.22, 0.61, 0.36, 1] as const },
  });

  return (
    <section ref={rootRef} className="relative flex min-h-[720px] w-full items-center overflow-hidden bg-[#071814] text-white">
      <canvas ref={canvasRef} className="pointer-events-none absolute inset-0 h-full w-full" aria-hidden="true" />
      {/* Shade behind the copy: from the left on wide screens, from the top on phones. */}
      <div className="pointer-events-none absolute inset-0 hidden md:block" style={{ background: "linear-gradient(90deg, rgba(5,18,15,0.94) 0%, rgba(5,18,15,0.8) 34%, rgba(5,18,15,0) 68%)" }} aria-hidden="true" />
      <div className="pointer-events-none absolute inset-0 md:hidden" style={{ background: "linear-gradient(180deg, rgba(5,18,15,0.9) 0%, rgba(5,18,15,0.82) 52%, rgba(5,18,15,0) 82%)" }} aria-hidden="true" />

      {/* Survey marker: a crosshair with the height under it. */}
      <div ref={markerRef} className="pointer-events-none absolute left-0 top-0" aria-hidden="true">
        <span className="absolute -left-5 top-0 h-px w-10 bg-amber-200/80" />
        <span className="absolute -top-5 left-0 h-10 w-px bg-amber-200/80" />
        <span className="absolute -left-1.5 -top-1.5 h-3 w-3 rounded-full border border-amber-200 bg-[#071814]" />
        <span className="absolute left-4 top-3 whitespace-nowrap rounded bg-amber-200 px-2 py-1 font-mono text-[11px] font-semibold text-emerald-950">
          <span ref={readoutRef}>1,480 m</span>
        </span>
      </div>

      <p className="pointer-events-none absolute right-5 top-5 hidden font-mono text-[11px] tracking-widest text-emerald-100/70 sm:block" aria-hidden="true">
        N 46&deg;33&prime;12&Prime; &nbsp; E 8&deg;33&prime;40&Prime;
      </p>
      <p className="pointer-events-none absolute bottom-5 right-5 hidden font-mono text-[11px] tracking-widest text-emerald-100/70 sm:block" aria-hidden="true">
        SHEET 1231 &middot; URSEREN
      </p>

      <div className="relative z-10 mx-auto w-full max-w-6xl px-6 py-20 sm:px-10">
        <div className="max-w-xl">
          <motion.p {...rise(0.05)} className="inline-flex items-center gap-2 font-mono text-xs uppercase tracking-[0.22em] text-amber-200">
            <svg viewBox="0 0 20 20" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" aria-hidden="true">
              <path d="M3 16 8 6l3 5 2-3 4 8Z" />
            </svg>
            Cairnline maps
          </motion.p>
          <motion.h1 {...rise(0.15)} className="mt-6 text-5xl font-semibold leading-[1.02] tracking-tight sm:text-7xl">
            Know the ground before you walk it.
          </motion.h1>
          <motion.p {...rise(0.3)} className="mt-6 max-w-md text-base leading-relaxed text-emerald-50/90 sm:text-lg">
            Survey grade contour maps for every ridge, col and valley in the Alps, drawn fresh each season and saved to your phone for when the signal goes.
          </motion.p>
          <motion.div {...rise(0.42)} className="mt-8 flex flex-col gap-3 sm:flex-row">
            <a href="#" className="inline-flex h-12 items-center justify-center rounded-md bg-amber-200 px-6 text-sm font-semibold text-emerald-950 transition hover:bg-amber-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-200 focus-visible:ring-offset-2 focus-visible:ring-offset-[#071814]">
              Download the maps
            </a>
            <a href="#" className="inline-flex h-12 items-center justify-center rounded-md border border-emerald-100/40 px-6 text-sm font-semibold text-white transition hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-200 focus-visible:ring-offset-2 focus-visible:ring-offset-[#071814]">
              Plan a route
            </a>
          </motion.div>
          <motion.dl {...rise(0.56)} className="mt-12 flex flex-wrap gap-x-10 gap-y-5 border-t border-emerald-100/20 pt-6">
            {facts.map((f) => (
              <div key={f.label}>
                <dt className="sr-only">{f.label}</dt>
                <dd className="font-mono text-lg font-semibold text-white">{f.value}</dd>
                <dd className="mt-1 text-xs uppercase tracking-widest text-emerald-100/80" aria-hidden="true">
                  {f.label}
                </dd>
              </div>
            ))}
          </motion.dl>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/topographic-contour-lines-background-with-morphing-terrain.json

When to use it

How to customize it

Accessibility

Questions

How are the contour lines drawn?

With marching squares. The hero is sampled on a grid, and for each small square the code checks which corners are above a given height. That tells it which edges the line crosses, and linear interpolation along each edge finds exactly where. Joining those crossings square by square produces smooth, closed contours.

Where does the terrain come from?

From gradient noise, the same family of function used for clouds and terrain in games. Two layers at different scales are added together, and time is fed in as a third dimension, so the whole landscape changes shape smoothly instead of sliding sideways.

How does the hill under the pointer work?

Before the lines are traced, a bell shaped bump centred on the crosshair is added to every sample. Because the contours are recalculated from the heights each frame, they wrap around the bump as rings without any special drawing code.

Is it expensive to run?

At the default `GRID` of 11 pixels a laptop hero has about seven thousand samples. Each frame evaluates the noise at those points and strokes three paths, which fits in a couple of milliseconds. Raise `GRID` to 14 or 16 to make it lighter.

Can I use it on a light background?

Yes. Set the section background and the `ground`, `high` and `water` colours to pale tones, change the three stroke colours to darker inks and flip the shade behind the copy from dark to light.

Related: Flowing Wave Lines Hero Background, Particle Flow Field Canvas Background, Dot Matrix Background with Ripples and Pointer Spotlight.

More animated backgrounds or all free UI components. Start for free to have a full site built around it.

More in Backgrounds

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap