Contour
Living topographic map: marching-squares contour lines over an evolving 3D value-noise terrain, thin minor strokes with a bold accent index line every fifth level, over soft posterized elevation bands. Cool cartographic default. Canvas 2D, seeded, offscreen-paused, DPR-clamped, reduced-motion-safe.
canvasfree
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
import { useVisibilityPause } from "@/registry/default/hooks/use-visibility-pause/use-visibility-pause";
export interface ContourProps extends React.ComponentPropsWithoutRef<"div"> {
/**
* Two tones `[line, accent]`. `line` colors the thin minor contour lines;
* `accent` colors the bolder index line (every fifth contour) and tints the
* soft elevation bands toward the peaks. Defaults to a cool cartographic
* slate-on-ice. Pass warm tones for a topographic-heat look.
* @default ["#64788f", "#dbe9fb"]
*/
colors?: [string, string];
/** Morph-speed multiplier — how fast the terrain evolves. 1 = default. @default 1 */
speed?: number;
/** Line brightness / opacity, 0–2. @default 1 */
intensity?: number;
/**
* Spacing between contour lines in CSS px — the elevation interval. Smaller =
* denser lines (steep terrain read), larger = sparse. Clamped 12–80.
* @default 28
*/
lineGap?: number;
/** devicePixelRatio ceiling. Clamped 1–2. @default 2 */
dpr?: number;
/** Freeze the map at a static, fully-drawn topographic frame. @default false */
paused?: boolean;
}
const DEFAULT_COLORS: [string, string] = ["#64788f", "#dbe9fb"];
/** Cool near-black valley floor the map sits on — never flat #000. */
const BG_RGB: [number, number, number] = [6, 9, 14];
/** Spatial size of one terrain "hill" in CSS px — sets feature scale. */
const FEATURE_PX = 300;
/** Target grid cell size in CSS px — finer = smoother contours, more cost. */
const CELL_PX = 14;
/** fBm octaves — 3 gives ridged terrain without going heavy. */
const OCTAVES = 3;
/** How fast the noise field morphs through its third (time) dimension. */
const Z_RATE = 0.12;
/** A slow horizontal drift so the map also creeps, not only morphs. */
const DRIFT_RATE = 0.015;
/** Frozen z-slice for the reduced-motion / paused frame (a handsome terrain). */
const Z_STATIC = 3.7;
/** Every Nth contour is promoted to a bold accent index line. */
const INDEX_EVERY = 5;
function hexToRgb(hex: string): [number, number, number] {
let h = hex.replace("#", "").trim();
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
const int = parseInt(h, 16);
if (Number.isNaN(int) || h.length !== 6) return [100, 120, 143];
return [(int >> 16) & 255, (int >> 8) & 255, int & 255];
}
/** Integer hash → [0,1), stable across SSR intent and client. */
function hash3(ix: number, iy: number, iz: number, seed: number): number {
let h = seed >>> 0;
h = Math.imul(h ^ (ix | 0), 0x27d4eb2f);
h = Math.imul(h ^ (iy | 0), 0x85ebca6b);
h = Math.imul(h ^ (iz | 0), 0xc2b2ae35);
h ^= h >>> 15;
return (h >>> 0) / 4294967296;
}
function fade(t: number): number {
return t * t * t * (t * (t * 6 - 15) + 10);
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
/** Trilinear value noise in 3D — smooth, seeded, morphs along z. */
function vnoise3(x: number, y: number, z: number, seed: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const iz = Math.floor(z);
const ux = fade(x - ix);
const uy = fade(y - iy);
const uz = fade(z - iz);
const c000 = hash3(ix, iy, iz, seed);
const c100 = hash3(ix + 1, iy, iz, seed);
const c010 = hash3(ix, iy + 1, iz, seed);
const c110 = hash3(ix + 1, iy + 1, iz, seed);
const c001 = hash3(ix, iy, iz + 1, seed);
const c101 = hash3(ix + 1, iy, iz + 1, seed);
const c011 = hash3(ix, iy + 1, iz + 1, seed);
const c111 = hash3(ix + 1, iy + 1, iz + 1, seed);
const x00 = lerp(c000, c100, ux);
const x10 = lerp(c010, c110, ux);
const x01 = lerp(c001, c101, ux);
const x11 = lerp(c011, c111, ux);
const y0 = lerp(x00, x10, uy);
const y1 = lerp(x01, x11, uy);
return lerp(y0, y1, uz);
}
/** Fractal value noise, normalized to ~[0,1]. */
function fbm(x: number, y: number, z: number, seed: number): number {
let amp = 0.5;
let freq = 1;
let sum = 0;
let norm = 0;
for (let o = 0; o < OCTAVES; o++) {
sum += amp * vnoise3(x * freq, y * freq, z * freq + o * 13.7, seed + o * 101);
norm += amp;
amp *= 0.5;
freq *= 2;
}
return sum / norm;
}
interface Internals {
ctx: CanvasRenderingContext2D;
width: number;
height: number;
gx: number;
gy: number;
cw: number;
ch: number;
stepX: number;
stepY: number;
field: Float32Array;
cellMin: Float32Array;
cellMax: Float32Array;
fieldCanvas: HTMLCanvasElement;
fieldCtx: CanvasRenderingContext2D;
imgData: ImageData;
levels: number[];
lineRgb: [number, number, number];
accentRgb: [number, number, number];
peakRgb: [number, number, number];
intensity: number;
speed: number;
seed: number;
}
/** Elevation range the contour levels span (trims dead edges of the field). */
const LO = 0.08;
const HI = 0.92;
function buildLevels(lineGap: number): number[] {
// px spacing → contour count. 28px → ~13 lines; larger gap → fewer.
const count = Math.min(48, Math.max(5, Math.round(360 / lineGap)));
const span = HI - LO;
const out: number[] = [];
for (let k = 0; k < count; k++) out.push(LO + ((k + 0.5) / count) * span);
return out;
}
/** Recompute the scalar terrain field for the whole grid at time-slice z. */
function computeField(it: Internals, z: number, driftX: number) {
const { field, gx, gy, stepX, stepY, seed } = it;
const cols = gx + 1;
for (let j = 0; j <= gy; j++) {
const wy = j * stepY;
const row = j * cols;
for (let i = 0; i <= gx; i++) {
field[row + i] = fbm(i * stepX + driftX, wy, z, seed);
}
}
// Per-cell min/max so the contour pass can cheaply skip cells no level crosses.
const { cellMin, cellMax } = it;
for (let j = 0; j < gy; j++) {
const r0 = j * cols;
const r1 = r0 + cols;
for (let i = 0; i < gx; i++) {
const a = field[r0 + i];
const b = field[r0 + i + 1];
const c = field[r1 + i + 1];
const d = field[r1 + i];
let mn = a;
let mx = a;
if (b < mn) mn = b;
else if (b > mx) mx = b;
if (c < mn) mn = c;
else if (c > mx) mx = c;
if (d < mn) mn = d;
else if (d > mx) mx = d;
const ci = j * gx + i;
cellMin[ci] = mn;
cellMax[ci] = mx;
}
}
}
/** Paint the soft, posterized elevation bands beneath the lines. */
function paintBands(it: Internals) {
const { field, gx, gy, imgData, peakRgb } = it;
const cols = gx + 1;
const data = imgData.data;
const span = HI - LO;
// Band edges land on every ~3rd contour, so plateaus read as "filled bands".
const bands = Math.max(3, Math.round(it.levels.length / 3));
const [pr, pg, pb] = peakRgb;
const [br, bg, bb] = BG_RGB;
for (let j = 0; j <= gy; j++) {
const row = j * cols;
for (let i = 0; i <= gx; i++) {
const v = field[row + i];
// Normalize into the drawn elevation range, posterize into bands.
let t = (v - LO) / span;
t = t < 0 ? 0 : t > 1 ? 1 : t;
const shade = Math.floor(t * bands) / bands;
const p = (row + i) * 4;
data[p] = br + (pr - br) * shade;
data[p + 1] = bg + (pg - bg) * shade;
data[p + 2] = bb + (pb - bb) * shade;
data[p + 3] = 255;
}
}
it.fieldCtx.putImageData(imgData, 0, 0);
it.ctx.imageSmoothingEnabled = true;
it.ctx.drawImage(it.fieldCanvas, 0, 0, it.width, it.height);
}
/** Add the marching-squares segment(s) for one cell at one level to the path. */
function addCellSegments(
path: Path2D,
L: number,
x0: number,
y0: number,
cw: number,
ch: number,
vtl: number,
vtr: number,
vbr: number,
vbl: number
) {
const x1 = x0 + cw;
const y1 = y0 + ch;
// Edge crossing helpers (only the ones a case needs are evaluated).
const top = () => {
const d = vtr - vtl;
return x0 + cw * (Math.abs(d) < 1e-6 ? 0.5 : (L - vtl) / d);
};
const bottom = () => {
const d = vbr - vbl;
return x0 + cw * (Math.abs(d) < 1e-6 ? 0.5 : (L - vbl) / d);
};
const left = () => {
const d = vbl - vtl;
return y0 + ch * (Math.abs(d) < 1e-6 ? 0.5 : (L - vtl) / d);
};
const right = () => {
const d = vbr - vtr;
return y0 + ch * (Math.abs(d) < 1e-6 ? 0.5 : (L - vtr) / d);
};
const code =
(vtl > L ? 8 : 0) | (vtr > L ? 4 : 0) | (vbr > L ? 2 : 0) | (vbl > L ? 1 : 0);
switch (code) {
case 1: // BL
case 14: // ~BL
path.moveTo(x0, left());
path.lineTo(bottom(), y1);
break;
case 2: // BR
case 13: // ~BR
path.moveTo(bottom(), y1);
path.lineTo(x1, right());
break;
case 3: // BL+BR
case 12: // TL+TR
path.moveTo(x0, left());
path.lineTo(x1, right());
break;
case 4: // TR
case 11: // ~TR
path.moveTo(top(), y0);
path.lineTo(x1, right());
break;
case 6: // TR+BR
case 9: // TL+BL
path.moveTo(top(), y0);
path.lineTo(bottom(), y1);
break;
case 7: // ~TL
case 8: // TL
path.moveTo(top(), y0);
path.lineTo(x0, left());
break;
case 5: // TR+BL saddle
path.moveTo(top(), y0);
path.lineTo(x0, left());
path.moveTo(bottom(), y1);
path.lineTo(x1, right());
break;
case 10: // TL+BR saddle
path.moveTo(top(), y0);
path.lineTo(x1, right());
path.moveTo(x0, left());
path.lineTo(bottom(), y1);
break;
default: // 0 or 15 — no crossing
break;
}
}
/** One frame. `useStatic` renders the frozen z-slice with no drift. */
function draw(it: Internals, elapsed: number, useStatic: boolean) {
const z = useStatic ? Z_STATIC : elapsed * it.speed * Z_RATE;
const driftX = useStatic ? 0 : elapsed * it.speed * DRIFT_RATE;
computeField(it, z, driftX);
// Elevation bands first (opaque base), then the crisp lines on top.
paintBands(it);
const { ctx, field, gx, gy, cw, ch, cellMin, cellMax, levels } = it;
const cols = gx + 1;
const gInt = Math.min(Math.max(it.intensity, 0), 2);
ctx.imageSmoothingEnabled = true;
ctx.lineJoin = "round";
ctx.lineCap = "round";
for (let li = 0; li < levels.length; li++) {
const L = levels[li];
const isIndex = li % INDEX_EVERY === Math.floor(INDEX_EVERY / 2);
const rgb = isIndex ? it.accentRgb : it.lineRgb;
const alpha = Math.min((isIndex ? 0.92 : 0.5) * gInt, 1);
if (alpha < 0.01) continue;
const path = new Path2D();
for (let j = 0; j < gy; j++) {
const r0 = j * cols;
const r1 = r0 + cols;
const y0 = j * ch;
for (let i = 0; i < gx; i++) {
const ci = j * gx + i;
if (L < cellMin[ci] || L > cellMax[ci]) continue;
addCellSegments(
path,
L,
i * cw,
y0,
cw,
ch,
field[r0 + i],
field[r0 + i + 1],
field[r1 + i + 1],
field[r1 + i]
);
}
}
ctx.strokeStyle = `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${alpha})`;
ctx.lineWidth = isIndex ? 1.5 : 1;
ctx.stroke(path);
}
}
/**
* Contour — a living topographic map. Elevation contour lines traced by
* marching-squares over an evolving 3D value-noise terrain: thin minor lines
* with a bolder accent index line every fifth level, laid over soft posterized
* elevation bands that read as filled plateaus. The whole field slowly morphs
* and drifts like a survey map redrawing itself. Canvas 2D: seeded and
* SSR-safe, paused offscreen and on hidden tabs, DPR-clamped, reduced-motion-
* safe (a static, fully-drawn terrain). Drop it inside any `relative` container.
*/
export function Contour({
colors = DEFAULT_COLORS,
speed = 1,
intensity = 1,
lineGap = 28,
dpr = 2,
paused = false,
className,
...props
}: ContourProps) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const internalsRef = React.useRef<Internals | null>(null);
const reducedMotion = useReducedMotion();
const staticFrame = paused || reducedMotion;
const staticFrameRef = React.useRef(staticFrame);
staticFrameRef.current = staticFrame;
const gap = Math.min(Math.max(lineGap, 12), 80);
const colorsKey = colors.join(",");
const tick = React.useCallback((elapsed: number) => {
const it = internalsRef.current;
if (!it) return;
draw(it, elapsed, staticFrameRef.current);
}, []);
const containerRef = useVisibilityPause<HTMLDivElement>(tick, { paused: staticFrame });
React.useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const ctx = canvas.getContext("2d", { alpha: false });
if (!ctx) return;
const effectiveDpr = Math.min(
typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1,
Math.min(Math.max(dpr, 1), 2)
);
const lineRgb = hexToRgb(colors[0]);
const accentRgb = hexToRgb(colors[1]);
// Sunlit-peak tint: a dim wash of the accent hue over the valley floor.
const peakRgb: [number, number, number] = [
Math.min(255, (BG_RGB[0] + accentRgb[0] * 0.15) | 0),
Math.min(255, (BG_RGB[1] + accentRgb[1] * 0.15) | 0),
Math.min(255, (BG_RGB[2] + accentRgb[2] * 0.15) | 0),
];
const fieldCanvas = document.createElement("canvas");
const fieldCtx = fieldCanvas.getContext("2d", { alpha: false });
if (!fieldCtx) return;
const it: Internals = {
ctx,
width: 0,
height: 0,
gx: 0,
gy: 0,
cw: 0,
ch: 0,
stepX: 0,
stepY: 0,
field: new Float32Array(0),
cellMin: new Float32Array(0),
cellMax: new Float32Array(0),
fieldCanvas,
fieldCtx,
imgData: fieldCtx.createImageData(1, 1),
levels: buildLevels(gap),
lineRgb,
accentRgb,
peakRgb,
intensity,
speed,
seed: 0x1234,
};
internalsRef.current = it;
const onContextLost = (e: Event) => e.preventDefault();
canvas.addEventListener("contextlost", onContextLost);
const resize = () => {
const w = Math.max(container.clientWidth, 1);
const h = Math.max(container.clientHeight, 1);
canvas.width = Math.round(w * effectiveDpr);
canvas.height = Math.round(h * effectiveDpr);
canvas.style.width = "100%";
canvas.style.height = "100%";
ctx.setTransform(effectiveDpr, 0, 0, effectiveDpr, 0, 0);
const gx = Math.min(200, Math.max(8, Math.round(w / CELL_PX)));
const gy = Math.min(200, Math.max(8, Math.round(h / CELL_PX)));
it.width = w;
it.height = h;
it.gx = gx;
it.gy = gy;
it.cw = w / gx;
it.ch = h / gy;
// Isotropic feature scale regardless of grid resolution.
it.stepX = (w / FEATURE_PX) / gx;
it.stepY = (h / FEATURE_PX) / gy;
it.field = new Float32Array((gx + 1) * (gy + 1));
it.cellMin = new Float32Array(gx * gy);
it.cellMax = new Float32Array(gx * gy);
fieldCanvas.width = gx + 1;
fieldCanvas.height = gy + 1;
it.imgData = fieldCtx.createImageData(gx + 1, gy + 1);
// Never flash empty — paint one frame immediately.
draw(it, 0, staticFrameRef.current);
};
const ro = new ResizeObserver(resize);
ro.observe(container);
resize();
return () => {
ro.disconnect();
canvas.removeEventListener("contextlost", onContextLost);
internalsRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dpr, gap, colorsKey, speed, intensity]);
// Re-render the frozen frame in place when paused / reduced-motion toggles.
React.useEffect(() => {
const it = internalsRef.current;
if (!it || !staticFrame) return;
draw(it, 0, true);
}, [staticFrame]);
return (
<div
ref={containerRef}
aria-hidden
data-crucible="contour"
className={cn("absolute inset-0 -z-10 overflow-hidden bg-neutral-950", className)}
{...props}
>
<canvas ref={canvasRef} className="block h-full w-full" />
{/* Soft vignette so the map sinks into the frame edges. */}
<div
className="pointer-events-none absolute inset-0"
style={{
background:
"radial-gradient(120% 120% at 50% 42%, transparent 58%, rgba(0,0,0,0.5) 100%)",
}}
/>
</div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/contourProps
| Prop | Type | Default | Description |
|---|---|---|---|
| colors | [string, string] | ["#64788f", "#dbe9fb"] | Two tones [line, accent]. line colors the thin minor contour lines; accent colors the bolder index line (every fifth contour) and tints the soft elevation bands toward the peaks. Defaults to a cool cartographic slate-on-ice. Pass warm tones for a topographic-heat look. |
| speed | number | 1 | Morph-speed multiplier — how fast the terrain evolves. 1 = default. |
| intensity | number | 1 | Line brightness / opacity, 0–2. |
| lineGap | number | 28 | Spacing between contour lines in CSS px — the elevation interval. Smaller = denser lines (steep terrain read), larger = sparse. Clamped 12–80. |
| dpr | number | 2 | devicePixelRatio ceiling. Clamped 1–2. |
| paused | boolean | false | Freeze the map at a static, fully-drawn topographic frame. |
Also accepts all props of React.ComponentPropsWithoutRef<"div"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, pauses offscreen and on hidden tabs, and passes className through.