Skip to content
2D

Contours

The lines come from the DERIVATIVE of the height field, not the height itself. Thresholding fract(height) directly gives contours whose thickness depends on how steeply the terrain changes — hairline on cliffs, bloated across plateaus, exactly where a map needs them even. Dividing by the rate of change normalises that out. fwidth() would be the usual way to get the derivative, but it needs an extension WebGL1 does not guarantee, so the gradient is sampled explicitly: two extra taps, and it works everywhere.

Loading preview…

Usage

/* Deepak Kumar E — https://craft.iam-deepak.space */ import { Contours } from "@/components/contours"; <Contours />

Customise

3
14
1.4
1

The component

components/craft/2d/Contours.tsx

/* Deepak Kumar E — https://craft.iam-deepak.space */ import { useEffect, useRef } from "react"; /** * Topographic contour lines over a slowly shifting terrain. * * The lines come from the DERIVATIVE of the height field, not from the height * itself. Thresholding fract(height) directly gives lines whose apparent * thickness depends on how steeply the terrain is changing — hairline on * cliffs, bloated across plateaus, exactly where a map needs them evenly * weighted. Dividing by the rate of change normalises that out, so every * contour is the same width regardless of gradient. * * fwidth() would be the usual way to get that derivative, but it needs an * extension in WebGL1 that is not universal, so the gradient is computed * explicitly. It costs two extra field samples and works everywhere. */ const VERTEX = `attribute vec2 position;void main() { gl_Position = vec4(position, 0.0, 1.0); }`; const FRAGMENT = `precision highp float;uniform vec2  uResolution;uniform float uTime;uniform vec3  uColorLine;uniform vec3  uColorBase;uniform float uScale;uniform float uLevels;uniform float uThickness; float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } float noise(vec2 p) {  vec2 i = floor(p);  vec2 f = fract(p);  f = f * f * (3.0 - 2.0 * f);  return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x),             mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x), f.y);} /* Fractal sum: each octave adds finer relief at half the amplitude, which is   what makes the terrain read as landscape rather than as blobs. */float terrain(vec2 p) {  float h = 0.0;  float amp = 0.5;  for (int i = 0; i < 5; i++) {    h += noise(p) * amp;    p *= 2.02;    amp *= 0.5;  }  return h;} void main() {  vec2 uv = gl_FragCoord.xy / uResolution;  float aspect = uResolution.x / uResolution.y;  vec2 p = vec2(uv.x * aspect, uv.y) * uScale + vec2(uTime * 0.06, uTime * 0.02);   float h = terrain(p);   /* Explicit gradient: fwidth needs OES_standard_derivatives, which is not     guaranteed in WebGL1. Two extra samples buy universal support. */  float e = 1.5 / uResolution.y * uScale;  float dx = terrain(p + vec2(e, 0.0)) - h;  float dy = terrain(p + vec2(0.0, e)) - h;  float gradient = length(vec2(dx, dy)) + 1e-5;   float banded = fract(h * uLevels);  /* Distance to the nearest contour, normalised by slope so every line is the     same visual width however steep the terrain is under it. */  float dist = min(banded, 1.0 - banded) / (gradient * uLevels);  float line = 1.0 - smoothstep(0.0, uThickness, dist);   vec3 colour = mix(uColorBase, uColorLine, line);  /* A faint elevation tint, so the map reads as terrain and not as wallpaper. */  colour *= 0.65 + h * 0.7;  gl_FragColor = vec4(colour, 1.0);}`; function compile(gl: WebGLRenderingContext, type: number, src: string) {  const s = gl.createShader(type);  if (!s) return null;  gl.shaderSource(s, src);  gl.compileShader(s);  return gl.getShaderParameter(s, gl.COMPILE_STATUS) ? s : null;} function toRgb(hex: string): [number, number, number] {  const m = /^#?([\da-f]{6})$/i.exec(hex.trim());  if (!m) return [0, 0, 0];  const n = parseInt(m[1], 16);  return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];} export interface ContoursProps {  line?: string;  base?: string;  /** Terrain zoom. Higher packs more relief into the frame. */  scale?: number;  /** Contour intervals. Higher means more, closer lines. */  levels?: number;  thickness?: number;  speed?: number;  still?: boolean;  className?: string;} export function Contours({  line = "#c3f53c",  base = "#0a0b0e",  scale = 3,  levels = 14,  thickness = 1.4,  speed = 1,  still = false,  className = "",}: ContoursProps) {  const ref = useRef<HTMLCanvasElement>(null);   useEffect(() => {    const canvas = ref.current;    if (!canvas) return;    const gl = canvas.getContext("webgl", { alpha: false, antialias: false });    if (!gl) return;     const vs = compile(gl, gl.VERTEX_SHADER, VERTEX);    const fs = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT);    if (!vs || !fs) return;    const program = gl.createProgram();    if (!program) return;    gl.attachShader(program, vs);    gl.attachShader(program, fs);    gl.linkProgram(program);    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return;    gl.useProgram(program);     const buffer = gl.createBuffer();    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);    const position = gl.getAttribLocation(program, "position");    gl.enableVertexAttribArray(position);    gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);     const uniform = (n: string) => gl.getUniformLocation(program, n);    const uResolution = uniform("uResolution");    const uTime = uniform("uTime");    gl.uniform3fv(uniform("uColorLine"), toRgb(line));    gl.uniform3fv(uniform("uColorBase"), toRgb(base));    gl.uniform1f(uniform("uScale"), scale);    gl.uniform1f(uniform("uLevels"), levels);    gl.uniform1f(uniform("uThickness"), thickness);     const reduced =      still ||      (typeof matchMedia === "function" &&        matchMedia("(prefers-reduced-motion: reduce)").matches);     let uw = 0;    let uh = 0;    const resize = () => {      // Capped at 1.5: five octaves sampled three times per pixel makes      // resolution the dominant cost here.      const dpr = Math.min(devicePixelRatio || 1, 1.5);      const w = Math.floor(canvas.clientWidth * dpr);      const h = Math.floor(canvas.clientHeight * dpr);      if (!w || !h || (w === uw && h === uh)) return;      uw = w;      uh = h;      canvas.width = w;      canvas.height = h;      gl.viewport(0, 0, w, h);      gl.uniform2f(uResolution, w, h);    };     const start = performance.now();    const draw = (now: number) => {      resize();      gl.uniform1f(uTime, ((now - start) / 1000) * speed);      gl.drawArrays(gl.TRIANGLES, 0, 3);    };     let frame = 0;    const loop = (now: number) => {      draw(now);      frame = requestAnimationFrame(loop);    };     draw(start);    if (!reduced) frame = requestAnimationFrame(loop);     const observer = new ResizeObserver(() => {      resize();      if (reduced) draw(start);    });    observer.observe(canvas);     return () => {      cancelAnimationFrame(frame);      observer.disconnect();      gl.deleteProgram(program);      gl.deleteShader(vs);      gl.deleteShader(fs);      gl.deleteBuffer(buffer);    };  }, [line, base, scale, levels, thickness, speed, still]);   return <canvas ref={ref} aria-hidden className={`block h-full w-full ${className}`} />;} 

One file. Paste it in, delete what you do not need, change what you do. The "use client" directive is stripped above — add it back if you are on the Next.js App Router.

What it costs

Weight

+2.8KB gzipped · no new dependencies

Runs on

GPU

Work happens in a shader. The main thread only issues draw calls, so JavaScript stays free, but it costs GPU time and battery on mobile.

Reduced motion

Draws one frame of terrain and stops. A static map rather than a blank panel.

Accessibility
  • Canvas is aria-hidden; decorative.
  • No focusable elements.
  • Fine repeating lines can shimmer for some readers — the still path removes the movement entirely.
Watch out
  • Five noise octaves sampled three times per pixel for the gradient. Device pixel ratio is capped at 1.5 for that reason.
  • Needs WebGL; renders nothing without it.