# Wireframe Terrain (`wireframe-terrain`)

> An endless flight over wireframe mountains — perspective-projected ridge lines scroll toward the camera and dissolve into fog at the horizon, with a calm valley down the middle for your content.

- **Docs:** https://www.mellowui.com/components/wireframe-terrain
- **Markdown:** https://www.mellowui.com/components/wireframe-terrain.md
- **Registry:** https://www.mellowui.com/r/wireframe-terrain.json
- **Tool prompt:** https://www.mellowui.com/api/prompt/wireframe-terrain
- **Categories:** background, canvas, 3d, animation
- **Dependencies:** none

## AI prompt

Add a WireframeTerrain background from the mellow library — an endless flight over wireframe mountains with a calm valley down the middle for content. Wrap the hero content as `children`, and tune `speed`, `amplitude`, `horizon` and `cursor`. Leave `lineColor` unset so it follows the theme ink.

## Install

```bash
npx shadcn@latest add https://mellowui.com/r/wireframe-terrain.json
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `speed` | `number` | `1` | Flight speed multiplier. |
| `amplitude` | `number` | `1` | Mountain height multiplier. |
| `lineColor` | `string` | — | Wireframe colour — defaults to the theme ink. |
| `horizon` | `number` | `0.38` | Horizon position as a fraction of height, from the top. |
| `cursor` | `boolean` | `false` | Bank the camera toward the cursor. |
| `children` | `React.ReactNode` | — | Content rendered above the terrain. |
| `className` | `string` | — | Additional CSS classes. |

## Design notes

- Client component (`"use client"`) — required for animation and refs.
- Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors.
- Respects `prefers-reduced-motion: reduce` where applicable.
- Self-contained — no shared utilities. Copy-paste, not a package.

## Source

```tsx
"use client";

import React, { useEffect, useRef, useState } from "react";

function useInkRgb(): string {
  const [rgb, setRgb] = useState<string>("244, 241, 236");
  useEffect(() => {
    const read = () => {
      const v = getComputedStyle(document.documentElement)
        .getPropertyValue("--ink-rgb")
        .trim();
      if (v) setRgb(v);
    };
    read();
    const obs = new MutationObserver(read);
    obs.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class", "style", "data-theme"],
    });
    return () => obs.disconnect();
  }, []);
  return rgb;
}

function hash(x: number, y: number): number {
  const n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453;
  return n - Math.floor(n);
}

function vnoise(x: number, y: number): number {
  const ix = Math.floor(x), iy = Math.floor(y);
  const fx = x - ix, fy = y - iy;
  const ux = fx * fx * (3 - 2 * fx);
  const uy = fy * fy * (3 - 2 * fy);
  const ab = hash(ix, iy) + (hash(ix + 1, iy) - hash(ix, iy)) * ux;
  const cd = hash(ix, iy + 1) + (hash(ix + 1, iy + 1) - hash(ix, iy + 1)) * ux;
  return ab + (cd - ab) * uy;
}

function smoothRow(
  ctx: CanvasRenderingContext2D,
  xs: number[],
  ys: number[],
  cols: number
) {
  ctx.moveTo(xs[0], ys[0]);
  for (let c = 1; c < cols; c++) {
    const mx = (xs[c] + xs[c + 1]) * 0.5;
    const my = (ys[c] + ys[c + 1]) * 0.5;
    ctx.quadraticCurveTo(xs[c], ys[c], mx, my);
  }
  ctx.lineTo(xs[cols], ys[cols]);
}

export interface WireframeTerrainProps {
  /** Flight speed multiplier. */
  speed?: number;
  /** Mountain height multiplier. */
  amplitude?: number;
  /** Wireframe colour — defaults to the theme ink. */
  lineColor?: string;
  /** Horizon position as a fraction of height, from the top. */
  horizon?: number;
  /** Bank the camera toward the cursor. */
  cursor?: boolean;
  className?: string;
  style?: React.CSSProperties;
  children?: React.ReactNode;
}

const ROWS = 42;
const COLS = 56;
const Z_NEAR = 0.9;
const Z_FAR = 14;
const X_MAX = 11;

/**
 * An endless flight over wireframe mountains — perspective-projected ridge
 * lines scroll toward the camera and dissolve into fog at the horizon, with
 * a calm valley down the middle for your content.
 */
export function WireframeTerrain({
  speed = 1,
  amplitude = 1,
  lineColor,
  horizon = 0.38,
  cursor = false,
  className,
  style,
  children,
}: WireframeTerrainProps) {
  const inkRgb = useInkRgb();
  const wrapperRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const sizeRef = useRef({ w: 0, h: 0 });
  const cursorRef = useRef(0);

  useEffect(() => {
    if (!cursor) return;
    if (!wrapperRef.current) return;
    const wrapper: HTMLDivElement = wrapperRef.current;
    const onMove = (e: MouseEvent) => {
      const rect = wrapper.getBoundingClientRect();
      cursorRef.current = ((e.clientX - rect.left) / rect.width - 0.5) * 2;
    };
    const onLeave = () => {
      cursorRef.current = 0;
    };
    wrapper.addEventListener("mousemove", onMove);
    wrapper.addEventListener("mouseleave", onLeave);
    return () => {
      wrapper.removeEventListener("mousemove", onMove);
      wrapper.removeEventListener("mouseleave", onLeave);
    };
  }, [cursor]);

  useEffect(() => {
    if (!canvasRef.current) return;
    const canvas: HTMLCanvasElement = canvasRef.current;
    const ctxOrNull = canvas.getContext("2d");
    if (!ctxOrNull) return;
    const ctx: CanvasRenderingContext2D = ctxOrNull;

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

    let rafId = 0;
    let zOff = 0;
    let bank = 0;
    let last = 0;

    function resize() {
      const dpr = Math.min(window.devicePixelRatio, 2);
      const rect = canvas.getBoundingClientRect();
      sizeRef.current = { w: rect.width, h: rect.height };
      canvas.width = rect.width * dpr;
      canvas.height = rect.height * dpr;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    }

    // ridged terrain with a calm valley down the centre
    const heightAt = (x: number, z: number) => {
      const ridge =
        Math.abs(vnoise(x * 0.48, z * 0.2) * 2 - 1) * 1.34 +
        (vnoise(x * 0.92 + 7.7, z * 0.38 + 3.1) - 0.5) * 0.24;
      // ridges begin just outside the content valley and grow outward
      const vRaw = Math.min(1, Math.max(0, (Math.abs(x) - 0.9) / 2.4));
      const valley = vRaw * vRaw * (3 - 2 * vRaw);
      return ridge * valley * 1.5 * amplitude;
    };

    function frame() {
      const { w, h } = sizeRef.current;
      if (w === 0 || h === 0) return;

      ctx.clearRect(0, 0, w, h);

      const horizonY = h * horizon;
      const camH = 2.6;
      const fov = w * 0.42;
      const camX = bank * 2.2;

      // project all vertices once
      const px: number[][] = [];
      const py: number[][] = [];
      for (let r = 0; r <= ROWS; r++) {
        // log spacing keeps screen-space row rhythm even
        const z = Z_NEAR * Math.pow(Z_FAR / Z_NEAR, r / ROWS);
        const scale = fov / z;
        const worldZ = z + zOff;
        px[r] = [];
        py[r] = [];
        for (let c = 0; c <= COLS; c++) {
          const x = (c / COLS - 0.5) * 2 * X_MAX;
          px[r][c] = w / 2 + (x - camX) * scale;
          py[r][c] = horizonY + (camH - heightAt(x, worldZ)) * scale * 0.42;
        }
      }

      const color = lineColor ?? `rgba(${inkRgb}, 1)`;
      ctx.strokeStyle = color;
      ctx.lineJoin = "round";

      // ridge lines, far to near so close rows read strongest
      for (let r = ROWS; r >= 0; r--) {
        ctx.globalAlpha = 0.05 + Math.pow(1 - r / ROWS, 1.6) * 0.32;
        ctx.lineWidth = 0.5 + (1 - r / ROWS) * 0.9;
        ctx.beginPath();
        smoothRow(ctx, px[r], py[r], COLS);
        ctx.stroke();
      }

      // sparse longitudinal lines for the mesh feel
      ctx.lineWidth = 0.5;
      for (let c = 0; c <= COLS; c += 7) {
        ctx.beginPath();
        ctx.moveTo(px[ROWS][c], py[ROWS][c]);
        for (let r = ROWS - 1; r >= 0; r--) ctx.lineTo(px[r][c], py[r][c]);
        ctx.globalAlpha = 0.1;
        ctx.stroke();
      }

    }

    function loop(now: number) {
      const dt = Math.min((now - last) / 1000, 1 / 20);
      last = now;
      zOff += dt * 1.5 * speed;
      bank += (cursorRef.current - bank) * Math.min(1, dt * 3);
      frame();
      rafId = requestAnimationFrame(loop);
    }

    const ro = new ResizeObserver(() => {
      resize();
      if (reduced) frame();
    });
    ro.observe(canvas);
    resize();

    if (reduced) {
      frame();
    } else {
      last = performance.now();
      rafId = requestAnimationFrame(loop);
    }

    return () => {
      cancelAnimationFrame(rafId);
      ro.disconnect();
    };
  }, [speed, amplitude, lineColor, horizon, inkRgb, cursor]);

  return (
    <div
      ref={wrapperRef}
      className={["relative overflow-hidden", className].filter(Boolean).join(" ")}
      style={style}
    >
      <canvas
        ref={canvasRef}
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 h-full w-full"
      />
      {children && <div className="absolute inset-0 z-[1]">{children}</div>}
    </div>
  );
}

export default WireframeTerrain;

```

## Demo

```tsx
"use client";

import React from "react";
import { WireframeTerrain } from "../mellow/wireframe-terrain";

export default function WireframeTerrainDemo() {
  return (
    <WireframeTerrain cursor className="h-full w-full">
      <div className="flex h-full flex-col items-center justify-center gap-4 text-center">
        <h2 className="max-w-md font-serif text-4xl leading-tight text-(--ink) italic">
          Night flight
        </h2>
        <p className="max-w-sm text-[0.875rem] leading-relaxed text-[rgba(var(--ink-rgb),0.6)]">
          An endless run over wireframe ridges — the valley below stays calm so
          your words can live there.
        </p>
      </div>
    </WireframeTerrain>
  );
}

```
