---
title: Contour
description: Drifting topographic isolines for quiet map-like fields.
group: Backgrounds
order: 12
new: true
---

A canvas height field rendered as contour lines. The ridges drift slowly, closer to a topo map than a particle grid.

```tsx
import { Contour } from "@/components/motion/contour"

export function Example() {
  return (
    <Contour
      className="h-[420px] w-full"
      color="#3b82f6"
      levels={9}
    />
  )
}
```

## Installation

```bash
npx @arctis-sh/@arctis-sh/ui@latest add contour
```

## Usage

```tsx
import { Contour } from "@/components/motion/contour"
```

```tsx
<Contour
  className="h-[420px] w-full"
  color="#3b82f6"
  levels={9}
/>
```

## API Reference

### Contour

| Prop | Type | Default |
| --- | --- | --- |
| color | string | #3b82f6 |
| backgroundColor | string | — |
| levels | number | 9 |
| cellSize | number | 18 |
| lineWidth | number | 1.15 |
| speed | number | 1 |
| warp | number | 1 |
| opacity | number | 0.55 |
| dpr | number | 1.5 |
| className | string | — |
| children | ReactNode | — |

Also accepts the other native `div` attributes. `cellSize` and `lineWidth` are in pixels.

## Source

```tsx
"use client";

import "@/lib/motion-styles";

import {
  useEffect,
  useRef,
  type ComponentProps,
  type CSSProperties,
  type ReactNode,
} from "react";
import { cn } from "@/lib/utils";

export type ContourProps = Omit<ComponentProps<"div">, "children" | "color"> & {
  /** Line stroke color. */
  color?: string;
  /** Optional solid fill behind the field. */
  backgroundColor?: string;
  /** How many isolines across the field. */
  levels?: number;
  /** Field sample spacing in px. Lower is denser. */
  cellSize?: number;
  /** Stroke width in px. */
  lineWidth?: number;
  /** Drift rate of the height field. */
  speed?: number;
  /** Extra fold in the field (0–2). */
  warp?: number;
  /** Master alpha (0–1). */
  opacity?: number;
  /** Cap for device pixel ratio. */
  dpr?: number;
  children?: ReactNode;
};

type Rgba = { r: number; g: number; b: number };

function parseColor(color: string): Rgba {
  if (typeof document === "undefined") return { r: 59, g: 130, b: 246 };
  const canvas = document.createElement("canvas");
  canvas.width = canvas.height = 1;
  const ctx = canvas.getContext("2d");
  if (!ctx) return { r: 59, g: 130, b: 246 };
  ctx.fillStyle = "#000";
  ctx.fillStyle = color;
  ctx.fillRect(0, 0, 1, 1);
  const [r = 59, g = 130, b = 246] = ctx.getImageData(0, 0, 1, 1).data;
  return { r, g, b };
}

function heightAt(
  x: number,
  y: number,
  w: number,
  h: number,
  t: number,
  warp: number,
) {
  const nx = w <= 1 ? 0 : x / w;
  const ny = h <= 1 ? 0 : y / h;
  const fold = Math.max(warp, 0);
  return (
    Math.sin((nx * 3.6 + t * 0.18) * Math.PI * 2) *
      Math.cos((ny * 2.8 - t * 0.14) * Math.PI * 2) +
    0.55 *
      Math.sin((nx * 6.4 + ny * 1.7 + t * 0.22) * Math.PI * 2) +
    0.35 *
      Math.cos((nx * 1.9 - ny * 5.2 - t * 0.1) * Math.PI * 2) +
    fold *
      0.28 *
      Math.sin((nx * ny * 9.5 + t * 0.16) * Math.PI * 2)
  );
}

function lerp(a: number, b: number, t: number) {
  return a + (b - a) * t;
}

function edgePoint(
  x0: number,
  y0: number,
  x1: number,
  y1: number,
  v0: number,
  v1: number,
  level: number,
) {
  const d = v1 - v0;
  const t = Math.abs(d) < 1e-6 ? 0.5 : (level - v0) / d;
  return {
    x: lerp(x0, x1, t),
    y: lerp(y0, y1, t),
  };
}

function Contour({
  className,
  color = "#3b82f6",
  backgroundColor,
  levels = 9,
  cellSize = 18,
  lineWidth = 1.15,
  speed = 1,
  warp = 1,
  opacity = 0.55,
  dpr: dprCap = 1.5,
  children,
  style,
  ...props
}: ContourProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const paramsRef = useRef({
    color,
    backgroundColor,
    levels,
    cellSize,
    lineWidth,
    speed,
    warp,
    opacity,
    dprCap,
  });

  paramsRef.current = {
    color,
    backgroundColor,
    levels,
    cellSize,
    lineWidth,
    speed,
    warp,
    opacity,
    dprCap,
  };

  useEffect(() => {
    const root = rootRef.current;
    const canvas = canvasRef.current;
    if (!root || !canvas) return;

    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    let raf = 0;
    let running = true;
    let inView = true;
    let last = performance.now();
    let time = 0;
    let colorKey = "";
    let rgba: Rgba = { r: 59, g: 130, b: 246 };
    let layoutKey = "";
    let cssW = 1;
    let cssH = 1;
    let cols = 1;
    let rows = 1;
    let field = new Float32Array(4);

    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");

    function rebuild() {
      const p = paramsRef.current;
      cssW = Math.max(root!.clientWidth, 1);
      cssH = Math.max(root!.clientHeight, 1);
      const dpr = Math.min(window.devicePixelRatio || 1, Math.max(p.dprCap, 1));
      const cell = Math.max(p.cellSize, 8);
      cols = Math.max(Math.ceil(cssW / cell), 2);
      rows = Math.max(Math.ceil(cssH / cell), 2);
      field = new Float32Array((cols + 1) * (rows + 1));
      canvas!.width = Math.floor(cssW * dpr);
      canvas!.height = Math.floor(cssH * dpr);
      canvas!.style.width = `${cssW}px`;
      canvas!.style.height = `${cssH}px`;
      layoutKey = `${cssW}x${cssH}:${cell}:${p.dprCap}`;
    }

    function ensureLayout() {
      const p = paramsRef.current;
      const next = `${Math.max(root!.clientWidth, 1)}x${Math.max(root!.clientHeight, 1)}:${Math.max(p.cellSize, 8)}:${p.dprCap}`;
      if (next !== layoutKey) rebuild();
    }

    function sample(t: number) {
      const p = paramsRef.current;
      const cellW = cssW / cols;
      const cellH = cssH / rows;
      const fold = Math.max(p.warp, 0);
      for (let y = 0; y <= rows; y++) {
        for (let x = 0; x <= cols; x++) {
          field[y * (cols + 1) + x] = heightAt(
            x * cellW,
            y * cellH,
            cssW,
            cssH,
            t,
            fold,
          );
        }
      }
    }

    function paint(now: number) {
      if (!running) return;
      ensureLayout();
      const p = paramsRef.current;
      const dt = Math.min((now - last) / 1000, 0.05);
      last = now;

      if (inView && !reduced.matches) {
        time += dt * Math.max(p.speed, 0);
      }

      if (p.color !== colorKey) {
        rgba = parseColor(p.color);
        colorKey = p.color;
      }

      const dpr = Math.min(window.devicePixelRatio || 1, Math.max(p.dprCap, 1));
      const master = Math.min(Math.max(p.opacity, 0), 1);
      const levelCount = Math.max(Math.round(p.levels), 2);
      const stroke = Math.max(p.lineWidth, 0.4) * dpr;

      sample(reduced.matches ? 0 : time);

      ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx!.clearRect(0, 0, cssW, cssH);

      if (p.backgroundColor) {
        ctx!.fillStyle = p.backgroundColor;
        ctx!.fillRect(0, 0, cssW, cssH);
      }

      ctx!.lineWidth = stroke;
      ctx!.lineJoin = "round";
      ctx!.lineCap = "round";

      const cellW = cssW / cols;
      const cellH = cssH / rows;
      const stride = cols + 1;

      for (let i = 0; i < levelCount; i++) {
        const level = -1 + (2 * (i + 0.5)) / levelCount;
        const alpha = master * (0.35 + (0.65 * (i + 1)) / levelCount);
        ctx!.strokeStyle = `rgba(${rgba.r}, ${rgba.g}, ${rgba.b}, ${alpha})`;
        ctx!.beginPath();

        for (let row = 0; row < rows; row++) {
          for (let col = 0; col < cols; col++) {
            const x0 = col * cellW;
            const y0 = row * cellH;
            const x1 = x0 + cellW;
            const y1 = y0 + cellH;

            const v00 = field[row * stride + col] ?? 0;
            const v10 = field[row * stride + col + 1] ?? 0;
            const v01 = field[(row + 1) * stride + col] ?? 0;
            const v11 = field[(row + 1) * stride + col + 1] ?? 0;

            let code = 0;
            if (v00 >= level) code |= 1;
            if (v10 >= level) code |= 2;
            if (v11 >= level) code |= 4;
            if (v01 >= level) code |= 8;
            if (code === 0 || code === 15) continue;

            const top = edgePoint(x0, y0, x1, y0, v00, v10, level);
            const right = edgePoint(x1, y0, x1, y1, v10, v11, level);
            const bottom = edgePoint(x0, y1, x1, y1, v01, v11, level);
            const left = edgePoint(x0, y0, x0, y1, v00, v01, level);

            const segments: Array<[{ x: number; y: number }, { x: number; y: number }]> =
              [];

            switch (code) {
              case 1:
              case 14:
                segments.push([left, top]);
                break;
              case 2:
              case 13:
                segments.push([top, right]);
                break;
              case 3:
              case 12:
                segments.push([left, right]);
                break;
              case 4:
              case 11:
                segments.push([right, bottom]);
                break;
              case 5:
                segments.push([left, top], [right, bottom]);
                break;
              case 6:
              case 9:
                segments.push([top, bottom]);
                break;
              case 7:
              case 8:
                segments.push([left, bottom]);
                break;
              case 10:
                segments.push([top, right], [left, bottom]);
                break;
              default:
                break;
            }

            for (const [a, b] of segments) {
              ctx!.moveTo(a.x, a.y);
              ctx!.lineTo(b.x, b.y);
            }
          }
        }

        ctx!.stroke();
      }

      raf = window.requestAnimationFrame(paint);
    }

    rebuild();
    last = performance.now();
    raf = window.requestAnimationFrame(paint);

    const resizeObserver = new ResizeObserver(() => rebuild());
    resizeObserver.observe(root);

    const intersectionObserver = new IntersectionObserver(
      ([entry]) => {
        inView = entry?.isIntersecting ?? true;
      },
      { threshold: 0 },
    );
    intersectionObserver.observe(canvas);

    return () => {
      running = false;
      window.cancelAnimationFrame(raf);
      resizeObserver.disconnect();
      intersectionObserver.disconnect();
    };
  }, []);

  return (
    <div
      ref={rootRef}
      data-slot="contour"
      className={cn("arctis-contour relative overflow-hidden", className)}
      style={style as CSSProperties}
      {...props}
    >
      <canvas
        ref={canvasRef}
        className="arctis-contour-canvas pointer-events-none absolute inset-0 size-full"
        aria-hidden="true"
      />
      {children ? (
        <div className="relative z-10">{children}</div>
      ) : null}
    </div>
  );
}

export { Contour };
```
