Contour lines
Topographic lines drifting over a noise height field, one hairline wide at every slope.
npx shadcn@latest add https://ui.goodtabs.co/r/contour-lines.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/contour-lines
Sixteen lines per unit of height, with a heading on a soft clearing of the page colour.
Dense
Density 30 at 0.7 speed, as a full-bleed texture with nothing on top.
Usage
import { ContourLines } from "@/components/ui/contour-lines"
<ContourLines className="grid h-96 place-items-center">
<h2 className="bg-[radial-gradient(closest-side,var(--background)_55%,transparent)] p-16">Surfaces step up</h2>
</ContourLines>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| density | number | 16 | Lines per unit of height. Higher packs them closer. |
| speed | number | 1 | Multiplies how fast the terrain shifts. |
Source
contour-lines/contour-lines.tsx
"use client";
import * as React from "react";
import { ShaderCanvas } from "@/components/ui/shader-canvas";
import { cn } from "@/lib/utils";
const FRAGMENT = `#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
#else
precision mediump float;
#endif
uniform float uTime;
uniform vec2 uResolution;
uniform float uScale;
uniform vec3 uInk;
uniform float uDensity;
uniform float uSpeed;
const mat2 TURN = mat2(0.8, 0.6, -0.6, 0.8);
// A height field from three turned octaves of sine products, warped once so ridges wander.
float height(vec2 p, float t) {
p += 0.4 * vec2(sin(p.y * 0.7 + t), cos(p.x * 0.6 - t * 0.8));
float sum = 0.0;
float weight = 0.5;
for (int i = 0; i < 3; i++) {
sum += weight * sin(p.x + t) * sin(p.y - t * 0.7);
p = TURN * p * 1.9 + 1.1;
weight *= 0.45;
}
return sum;
}
void main() {
float unit = uScale * 170.0;
vec2 p = (gl_FragCoord.xy - 0.5 * uResolution) / unit;
float t = uTime * uSpeed * 0.035;
// Height at this pixel and one step along each axis. The step is 1.5 CSS pixels, so this is the
// slope per device pixel, measured by hand because the derivatives extension is not relied on.
float e = 1.5 / 170.0;
float h = height(p, t);
vec2 slope = vec2(height(p + vec2(e, 0.0), t) - h, height(p + vec2(0.0, e), t) - h) / (1.5 * uScale);
// Distance to the nearest level, divided by the slope, is distance in pixels on screen. That is
// what keeps a line one pixel wide on a cliff and on a plain alike.
float level = h * uDensity;
float near = abs(fract(level - 0.5) - 0.5);
float pixels = near / max(length(slope) * uDensity, 0.00001);
float width = 0.5 * uScale;
float line = 1.0 - smoothstep(width - 0.5, width + 0.5, pixels);
// Every fifth line is an index contour, as on a survey map.
float index = 1.0 - step(0.5, mod(floor(level + 0.5) + 200.0, 5.0));
float alpha = line * mix(0.2, 0.46, index);
gl_FragColor = vec4(uInk * alpha, alpha);
}`;
const TOKENS = { uInk: "--foreground" };
type ContourLinesProps = React.ComponentProps<"div"> & {
/** Lines per unit of height. Higher packs them closer. */
density?: number;
/** Multiplies how fast the terrain shifts. */
speed?: number;
};
/** Topographic lines drifting over a noise height field, one hairline wide at every slope. */
function ContourLines({ density = 16, speed = 1, className, children, ...props }: ContourLinesProps) {
const uniforms = React.useMemo(() => ({ uDensity: Math.max(density, 1), uSpeed: speed }), [density, speed]);
return (
<div
data-slot="contour-lines"
className={cn("relative isolate overflow-hidden bg-background", className)}
{...props}
>
<ShaderCanvas fragment={FRAGMENT} uniforms={uniforms} tokens={TOKENS} maxDpr={2} stillAt={30} className="-z-10">
<div className="absolute inset-0 bg-[repeating-radial-gradient(circle_at_30%_40%,transparent_0,transparent_31px,var(--border)_32px)]" />
</ShaderCanvas>
{children}
</div>
);
}
export { ContourLines };
components/ui/shader-canvas.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
type UniformValue = number | readonly number[];
type ShaderCanvasProps = React.ComponentProps<"div"> & {
/** Fragment shader source. Receives uTime, uResolution (device px), uScale (device px per CSS px), uPointer and uLoaded, plus your uniforms and tokens. */
fragment: string;
/** Extra uniforms. Read every frame, so a tween can mutate this object in place. */
uniforms?: Record<string, UniformValue>;
/** Theme tokens passed as vec3 uniforms, for example { uInk: "--foreground" }. Re-read on theme change. */
tokens?: Record<string, string>;
/** Images passed as sampler2D uniforms, for example { uImage: "/photo.jpg" }. uImageSize-style sizes follow: uImage gets uImageSize. */
textures?: Record<string, string>;
/** Upper limit on device pixels per CSS pixel. */
maxDpr?: number;
/** The moment, in seconds, drawn as the still frame under reduced motion. */
stillAt?: number;
/** "demand" draws only when `invalidateRef` is called, for pieces that rest. The default never stops while visible. */
frames?: "continuous" | "demand";
/** Receives a function that draws one frame now. Pair it with frames="demand". */
invalidateRef?: React.RefObject<(() => void) | null>;
/** "track" feeds a smoothed pointer position to uPointer on devices with a mouse. */
pointer?: "none" | "track";
};
const VERTEX = `attribute vec2 aPosition;
void main() { gl_Position = vec4(aPosition, 0.0, 1.0); }`;
/** Resolves a token through the cascade, so a scoped .light or .dark wrapper is respected. */
function readToken(host: HTMLElement, token: string): [number, number, number] {
const probe = document.createElement("span");
probe.style.color = `var(${token})`;
probe.style.display = "none";
host.appendChild(probe);
const color = getComputedStyle(probe).color;
probe.remove();
const paint = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
if (!paint) return [0, 0, 0];
paint.fillStyle = color;
paint.fillRect(0, 0, 1, 1);
const [r, g, b] = paint.getImageData(0, 0, 1, 1).data;
return [r / 255, g / 255, b / 255];
}
function compile(gl: WebGLRenderingContext, type: number, source: string) {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
console.error(gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
/**
* A raw WebGL fragment shader on a full-bleed canvas. It pauses off screen, draws one still frame
* under reduced motion, follows the theme, and leaves its children showing when WebGL is missing.
* Children are the static fallback, so give it something that reads on its own.
*/
function ShaderCanvas({
fragment,
uniforms,
tokens,
textures,
maxDpr = 1.5,
stillAt = 2,
frames = "continuous",
invalidateRef,
pointer = "none",
className,
children,
...props
}: ShaderCanvasProps) {
const host = React.useRef<HTMLDivElement>(null);
const canvas = React.useRef<HTMLCanvasElement>(null);
const latest = React.useRef({ uniforms, tokens });
const textureKey = JSON.stringify(textures ?? {});
const redraw = React.useRef<() => void>(() => {});
const [ready, setReady] = React.useState(false);
// Bumped when a lost context comes back, which rebuilds everything from scratch.
const [generation, setGeneration] = React.useState(0);
React.useEffect(() => {
latest.current = { uniforms, tokens };
redraw.current();
});
React.useEffect(() => {
const root = host.current;
const el = canvas.current;
if (!root || !el) return;
// WebGL clears its buffer once a frame is presented. A canvas that draws rarely has to keep it,
// or the picture is gone the next time the browser re-composites the page.
const motion = window.matchMedia("(prefers-reduced-motion: reduce)");
const gl = el.getContext("webgl", {
preserveDrawingBuffer: motion.matches || frames === "demand",
alpha: true,
antialias: false,
depth: false,
stencil: false,
premultipliedAlpha: true,
powerPreference: "low-power",
});
if (!gl) return;
const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX);
const frag = compile(gl, gl.FRAGMENT_SHADER, fragment);
const program = gl.createProgram();
if (!vertex || !frag || !program) return;
gl.attachShader(program, vertex);
gl.attachShader(program, frag);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error(gl.getProgramInfoLog(program));
return;
}
gl.useProgram(program);
// One oversized triangle covers the clip square with no diagonal seam.
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, "aPosition");
gl.enableVertexAttribArray(position);
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
const locations = new Map<string, WebGLUniformLocation | null>();
const at = (name: string) => {
if (!locations.has(name)) locations.set(name, gl.getUniformLocation(program, name));
return locations.get(name) ?? null;
};
const set = (name: string, value: UniformValue) => {
const loc = at(name);
if (!loc) return;
if (typeof value === "number") gl.uniform1f(loc, value);
else if (value.length === 2) gl.uniform2f(loc, value[0], value[1]);
else if (value.length === 3) gl.uniform3f(loc, value[0], value[1], value[2]);
else if (value.length === 4) gl.uniform4f(loc, value[0], value[1], value[2], value[3]);
};
let colors: Record<string, [number, number, number]> = {};
const readTheme = () => {
colors = Object.fromEntries(
Object.entries(latest.current.tokens ?? {}).map(([name, token]) => [name, readToken(root, token)]),
);
};
readTheme();
// Textures load after the first frame. A sampler with nothing bound reads as black, so shaders that
// use one should fade in on uLoaded, which goes from 0 to 1 once every image has arrived.
const sources = Object.entries(JSON.parse(textureKey) as Record<string, string>);
const images: WebGLTexture[] = [];
let loaded = sources.length === 0 ? 1 : 0;
let disposed = false;
let pending = sources.length;
sources.forEach(([name, src], unit) => {
const texture = gl.createTexture();
if (!texture) return;
images.push(texture);
const image = new Image();
image.crossOrigin = "anonymous";
image.onload = () => {
if (disposed) return;
gl.activeTexture(gl.TEXTURE0 + unit);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
const sampler = at(name);
if (sampler) gl.uniform1i(sampler, unit);
set(`${name}Size`, [image.naturalWidth, image.naturalHeight]);
pending -= 1;
if (pending === 0) loaded = 1;
draw();
};
image.src = src;
});
const target = { x: 0.5, y: 0.5 };
const eased = { x: 0.5, y: 0.5 };
let time = stillAt;
let visible = false;
let frame = 0;
let last = 0;
const draw = () => {
gl.viewport(0, 0, el.width, el.height);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
set("uTime", time);
set("uResolution", [el.width, el.height]);
set("uScale", el.width / Math.max(el.clientWidth, 1));
set("uPointer", [eased.x, eased.y]);
set("uLoaded", loaded);
for (const [name, value] of Object.entries(colors)) set(name, value);
for (const [name, value] of Object.entries(latest.current.uniforms ?? {})) set(name, value);
gl.drawArrays(gl.TRIANGLES, 0, 3);
};
redraw.current = () => {
if (motion.matches) draw();
};
const loop = (now: number) => {
frame = 0;
const dt = last ? Math.min((now - last) / 1000, 0.05) : 0;
last = now;
time += dt;
// Frame-rate independent smoothing: 1 - e^(-k dt).
const k = 1 - Math.exp(-6 * dt);
eased.x += (target.x - eased.x) * k;
eased.y += (target.y - eased.y) * k;
draw();
schedule();
};
const schedule = () => {
if (frames === "demand" || frame || !visible || motion.matches || document.hidden || gl.isContextLost()) return;
frame = requestAnimationFrame(loop);
};
const stop = () => {
if (frame) cancelAnimationFrame(frame);
frame = 0;
last = 0;
};
const resize = () => {
const scale = Math.min(window.devicePixelRatio || 1, maxDpr);
const width = Math.max(1, Math.round(el.clientWidth * scale));
const height = Math.max(1, Math.round(el.clientHeight * scale));
if (el.width !== width || el.height !== height) {
el.width = width;
el.height = height;
}
draw();
};
const resizer = new ResizeObserver(resize);
resizer.observe(el);
const viewport = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
if (!visible) return stop();
draw();
schedule();
});
viewport.observe(el);
// Smooth scrolling libraries flip classes on html while scrolling, so react only to the theme itself.
const themeKey = () => {
const html = document.documentElement;
return `${html.classList.contains("dark")} ${html.classList.contains("light")} ${html.style.colorScheme}`;
};
let lastTheme = themeKey();
const theme = new MutationObserver(() => {
if (themeKey() === lastTheme) return;
lastTheme = themeKey();
readTheme();
draw();
});
theme.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style"] });
const onVisibility = () => (document.hidden ? stop() : schedule());
const onMotion = () => {
stop();
if (motion.matches) {
time = stillAt;
draw();
} else schedule();
};
const onPointer = (e: PointerEvent) => {
const box = el.getBoundingClientRect();
target.x = (e.clientX - box.left) / box.width;
target.y = 1 - (e.clientY - box.top) / box.height;
};
const fine = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
const tracks = pointer === "track" && fine;
document.addEventListener("visibilitychange", onVisibility);
motion.addEventListener("change", onMotion);
if (tracks) window.addEventListener("pointermove", onPointer, { passive: true });
// preventDefault asks the browser to restore the context. Until then the static fallback shows.
const onLost = (e: Event) => {
e.preventDefault();
stop();
setReady(false);
};
const onRestored = () => setGeneration((n) => n + 1);
el.addEventListener("webglcontextlost", onLost);
el.addEventListener("webglcontextrestored", onRestored);
if (invalidateRef) invalidateRef.current = draw;
resize();
setReady(true);
return () => {
disposed = true;
if (invalidateRef) invalidateRef.current = null;
stop();
redraw.current = () => {};
resizer.disconnect();
viewport.disconnect();
theme.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
motion.removeEventListener("change", onMotion);
if (tracks) window.removeEventListener("pointermove", onPointer);
el.removeEventListener("webglcontextlost", onLost);
el.removeEventListener("webglcontextrestored", onRestored);
images.forEach((texture) => gl.deleteTexture(texture));
gl.deleteBuffer(buffer);
gl.deleteProgram(program);
gl.deleteShader(vertex);
gl.deleteShader(frag);
};
}, [fragment, frames, generation, invalidateRef, maxDpr, pointer, stillAt, textureKey]);
return (
<div
ref={host}
data-slot="shader-canvas"
aria-hidden="true"
className={cn("absolute inset-0", className)}
{...props}
>
{ready ? null : children}
<canvas ref={canvas} className="absolute inset-0 size-full" />
</div>
);
}
export { ShaderCanvas };
For AI agents
Hand your agent this page, the markdown at /components/contour-lines/prompt.md, or the Copy AI prompt button above.
When to use
- A hero or section background with structure and no colour
- Pages about depth, layers, maps or data
- A quiet full-bleed texture with one short heading on top
When not to use
- Behind text with nothing behind it: lines cut through letters
- Small areas under about 200px, where the terrain has no room to read
- More than two or three times on a page: browsers cap live WebGL contexts near 16
Agent notes
- The element needs a height. It fills its box and does nothing to set one
- It installs components/ui/shader-canvas.tsx, which pauses off screen, follows the theme and handles reduced motion
- Text on top needs a soft clearing in the page colour, because hairlines cross it otherwise
- Under reduced motion it draws one frame, and without WebGL CSS rings show through. No GSAP is involved here
The maths
- Slope comes from finite differences, (h(x + e) - h) / e, in three samples per pixel, with e at 1.5 CSS pixels.
- Distance to a line in pixels is |level - nearest| / (density × slope), so a line stays 1px wide on steep and flat ground.
- Every fifth level, mod(round(level), 5) = 0, is drawn at 2.3 times the alpha, like an index contour on a survey map.