# Contour — Animated Background for React

> 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.

- Page: https://crucibleui.com/components/backgrounds/contour
- Category: [backgrounds](https://crucibleui.com/backgrounds)
- Tier: free (full source)
- Engine: canvas

## Install

```bash
npx shadcn@latest add @crucible/contour
```

Registry JSON: https://crucibleui.com/r/contour.json

Import from `@/components/crucible/contour`.

Backgrounds render `absolute inset-0 -z-10`. Their parent must be a stacking context — wrap in `relative isolate` and put your content in a `relative z-10` child, or the background disappears behind an opaque ancestor.

## Props (`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: `React.ComponentPropsWithoutRef<"div">`.

## Usage example

`DemoScene` is a site-only preview harness, not part of the component — replace it with your own layout.

```tsx
import { Contour } from "@/components/crucible/contour";
import { DemoScene } from "@/components/registry/demo-scene";

export default function ContourDemo() {
  return (
    <DemoScene
      background={<Contour />}
      headline="Chart every elevation of your product"
      subhead="A living topographic map, redrawing itself behind your content."
    />
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through. Pauses offscreen and on hidden tabs, clamps device pixel ratio, and tears down its GL/canvas context on unmount.

## Source (`contour.tsx`)

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/hooks/use-reduced-motion";
import { useVisibilityPause } from "@/hooks/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;
  /** Speed-scaled terrain clock, accumulated per frame so speed changes never jump. */
  phase: number;
  lastElapsed: 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, useStatic: boolean) {
  const z = useStatic ? Z_STATIC : it.phase * Z_RATE;
  const driftX = useStatic ? 0 : it.phase * 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;
    let dt = elapsed - it.lastElapsed;
    it.lastElapsed = elapsed;
    if (dt > 0.1) dt = 0.1; // clamp tab-restore jumps
    if (dt < 0) dt = 0;
    it.phase += dt * it.speed;
    draw(it, 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,
      phase: internalsRef.current?.phase ?? 0,
      lastElapsed: internalsRef.current?.lastElapsed ?? 0,
    };
    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, 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]);

  // Live-sync speed / intensity without rebuilding the map.
  React.useEffect(() => {
    const it = internalsRef.current;
    if (!it) return;
    it.speed = speed;
    it.intensity = intensity;
    if (staticFrameRef.current) draw(it, true);
  }, [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, 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>
  );
}
```
