# Swell — Animated Background for React

> Sprung wave lattice: depth-graded rows of smooth lines ride an orbital noise field with woven coherence while a slow autonomous swell front rolls through every ~8s. Cursor swipes inject velocity impulses along the travel direction, proportional to cursor speed, then spring home on damped Hooke springs with a tanh soft-clip on stretch. Deep-sea steel default. Canvas 2D, seeded, offscreen-paused, DPR-clamped with device-pixel-aligned strokes, reduced-motion-safe (crest frozen mid-roll); idles on its own when untouched — a seeded ghost cursor swipes through.

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

## Install

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

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

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

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 (`SwellProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `orientation?` | `"horizontal" \| "vertical"` | `"horizontal"` | `"horizontal"` — rows of swell lines, depth rising toward the bottom of the frame (an open-sea read). `"vertical"` — hanging curtains of line with seeded depth layers. |
| `gap?` | `{ x: number; y: number }` | `{ x: 28, y: 26 }` | Lattice spacing in CSS px: `x` = horizontal spacing, `y` = vertical spacing. For horizontal orientation `y` sets the row gap and `x` the sample step along each row (vertical orientation swaps roles). Each axis clamped 8–200. |
| `amplitude?` | `number` | `16` | Idle wave amplitude in CSS px — how far points orbit their anchors. |
| `speed?` | `number` | `1` | Animation-speed multiplier for drift and the swell front. 1 = default. |
| `intensity?` | `number` | `1` | Line brightness, 0–2. |
| `tension?` | `number` | `150` | Spring stiffness pulling displaced points home (per s²). Higher = faster, tighter snap-back. Clamped 1–600. |
| `friction?` | `number` | `0.93` | Spring velocity retention per frame at 60fps, 0–0.999 (applied frame-rate-independently). Lower = deader, higher = wobblier. |
| `maxStretch?` | `number` | `48` | CSS-px displacement the cursor can stretch a point toward. Rendered through a tanh soft-clip, so stretch saturates smoothly — never a hard stop. |
| `swellInterval?` | `number` | `8` | Period in seconds of the autonomous swell front — a slow pressure wave that rolls through the lattice so the field breathes with no cursor. |
| `colors?` | `[string, string]` | `["#31404f", "#7dd3fc"]` | Two tones `[far, near]` graded across row depth. Defaults to deep-sea steel: dim slate far rows rising to cyan-tinged near rows. Pass warm tones for a dune-wind look. |
| `interactive?` | `boolean` | `true` | When true, cursor swipes inject velocity impulses along the travel direction, proportional to smoothed cursor speed, then spring back. |
| `idle?` | `boolean` | `true` | Idle autoplay: after ~1.5s with no real pointer, a seeded ghost cursor sweeps slow figure-eights through the lattice (with an occasional faster swipe) so thumbnails and heroes never sit still; the real pointer takes over instantly. Requires `interactive`. Never runs under reduced motion. |
| `seed?` | `number` | `1` | Deterministic seed for depth layout and drift phases. |
| `dpr?` | `number` | `2` | devicePixelRatio ceiling. Clamped 1–2. |
| `paused?` | `boolean` | `false` | Freeze at a static frame with the swell front mid-roll (never flat). |

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
"use client";

import { Swell } from "@/components/crucible/swell";
import { DemoScene } from "@/components/registry/demo-scene";

export default function SwellDemo() {
  return (
    <DemoScene
      background={<Swell />}
      headline="Depth your users can feel"
      subhead="Swipe across the lattice — every line springs back into the swell."
    />
  );
}
```

## 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 (`swell.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";

/**
 * The default spring feel of the lattice, exported so you can reuse or riff on
 * it: `tension` is the Hooke pull-back stiffness (per s²), `friction` the
 * per-frame velocity retention at 60fps (applied frame-rate-independently),
 * `maxStretch` the CSS-px displacement the soft-clip saturates toward.
 */
export const DEFAULT_SPRING = {
  tension: 150,
  friction: 0.93,
  maxStretch: 48,
} as const;

export interface SwellProps extends React.ComponentPropsWithoutRef<"div"> {
  /**
   * `"horizontal"` — rows of swell lines, depth rising toward the bottom of the
   * frame (an open-sea read). `"vertical"` — hanging curtains of line with
   * seeded depth layers. @default "horizontal"
   */
  orientation?: "horizontal" | "vertical";
  /**
   * Lattice spacing in CSS px: `x` = horizontal spacing, `y` = vertical
   * spacing. For horizontal orientation `y` sets the row gap and `x` the
   * sample step along each row (vertical orientation swaps roles). Each axis
   * clamped 8–200. @default { x: 28, y: 26 }
   */
  gap?: { x: number; y: number };
  /** Idle wave amplitude in CSS px — how far points orbit their anchors. @default 16 */
  amplitude?: number;
  /** Animation-speed multiplier for drift and the swell front. 1 = default. @default 1 */
  speed?: number;
  /** Line brightness, 0–2. @default 1 */
  intensity?: number;
  /**
   * Spring stiffness pulling displaced points home (per s²). Higher = faster,
   * tighter snap-back. Clamped 1–600. @default 150
   */
  tension?: number;
  /**
   * Spring velocity retention per frame at 60fps, 0–0.999 (applied
   * frame-rate-independently). Lower = deader, higher = wobblier. @default 0.93
   */
  friction?: number;
  /**
   * CSS-px displacement the cursor can stretch a point toward. Rendered
   * through a tanh soft-clip, so stretch saturates smoothly — never a hard
   * stop. @default 48
   */
  maxStretch?: number;
  /**
   * Period in seconds of the autonomous swell front — a slow pressure wave
   * that rolls through the lattice so the field breathes with no cursor.
   * @default 8
   */
  swellInterval?: number;
  /**
   * Two tones `[far, near]` graded across row depth. Defaults to deep-sea
   * steel: dim slate far rows rising to cyan-tinged near rows. Pass warm tones
   * for a dune-wind look. @default ["#31404f", "#7dd3fc"]
   */
  colors?: [string, string];
  /**
   * When true, cursor swipes inject velocity impulses along the travel
   * direction, proportional to smoothed cursor speed, then spring back.
   * @default true
   */
  interactive?: boolean;
  /**
   * Idle autoplay: after ~1.5s with no real pointer, a seeded ghost cursor
   * sweeps slow figure-eights through the lattice (with an occasional faster
   * swipe) so thumbnails and heroes never sit still; the real pointer takes
   * over instantly. Requires `interactive`. Never runs under reduced motion.
   * @default true
   */
  idle?: boolean;
  /** Deterministic seed for depth layout and drift phases. @default 1 */
  seed?: number;
  /** devicePixelRatio ceiling. Clamped 1–2. @default 2 */
  dpr?: number;
  /** Freeze at a static frame with the swell front mid-roll (never flat). @default false */
  paused?: boolean;
}

const DEFAULT_COLORS: [string, string] = ["#31404f", "#7dd3fc"];
/** Deep-sea near-black bed — cool navy, never flat #000. */
const BG: [number, number, number] = [4, 9, 15];

const TAU = Math.PI * 2;
/** Spatial size of one noise feature in CSS px. */
const FEATURE_PX = 240;
/** How many full turns the noise value winds the orbital angle through. */
const ANGLE_TURNS = 1.6;
/** Swell-front contribution to the orbital angle (radians at crest). */
const SWELL_ANGLE = 1.05;
/** Swell-front amplitude boost at crest (fraction of base amplitude). */
const SWELL_AMP = 0.45;
/** Frozen noise time for the reduced-motion / paused frame. */
const NOISE_T_STATIC = 3.7;
/** Cursor influence radius, CSS px. */
const POINTER_RADIUS = 130;
/** Impulse gain: cursor speed → point velocity injection. */
const INJECT = 3.1;
/** Smoothed cursor speed cap, CSS px/s. */
const MAX_POINTER_SPEED = 2600;
/** Extra kick on the transverse axis — the component the eye reads on a polyline. */
const TRANSVERSE_BOOST = 1.35;
/** Exponential time constants (seconds): enter/leave ease, velocity smoothing, velocity decay. */
const EASE_TAU = 0.22;
const VEL_TAU = 0.05;
const VEL_DECAY_TAU = 0.12;
/** Hard cap on total lattice points; the along-line step grows past this. */
const MAX_POINTS = 4200;
/** Ghost cursor takes over this long after the last real pointer movement. */
const GHOST_DELAY_MS = 1500;
/** Ghost presence — a softer hand than a real swipe. */
const GHOST_PRESENCE = 0.85;

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 [125, 211, 252];
  return [(int >> 16) & 255, (int >> 8) & 255, int & 255];
}

/** Deterministic mulberry32 PRNG — same seed, same sea. */
function mulberry32(seed: number): () => number {
  let a = seed >>> 0;
  return () => {
    a |= 0;
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

/** 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 (time). */
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);
  return lerp(lerp(x00, x10, uy), lerp(x01, x11, uy), uz);
}

/** Two-octave value noise, ~[0,1] — one main eval plus a cheap detail term. */
function fbm2(x: number, y: number, z: number, seed: number): number {
  return (
    vnoise3(x, y, z, seed) * 0.72 +
    vnoise3(x * 2.13, y * 2.13, z * 1.7 + 9.2, seed + 101) * 0.28
  );
}

interface Line {
  /** Index of the line's first point in the flat point arrays. */
  start: number;
  count: number;
  /** Depth 0 (far) → 1 (near): drives opacity, width, drift rate, amplitude. */
  z: number;
  rgb: [number, number, number];
  alphaBase: number;
  width: number;
  /** Per-row noise-drift rate — depth-graded so rows parallax. */
  tRate: number;
  tPhase: number;
}

interface Tuning {
  speed: number;
  intensity: number;
  amplitude: number;
  tension: number;
  friction: number;
  maxStretch: number;
  swellInterval: number;
  interactive: boolean;
  idle: boolean;
}

interface Internals {
  ctx: CanvasRenderingContext2D;
  w: number;
  h: number;
  horizontal: boolean;
  lines: Line[];
  /** Anchor positions, CSS px. */
  bx: Float32Array;
  by: Float32Array;
  /** Spring displacement + velocity per point. */
  sx: Float32Array;
  sy: Float32Array;
  vx: Float32Array;
  vy: Float32Array;
  /** Per-line scratch for displaced positions. */
  px: Float32Array;
  py: Float32Array;
  /** Swell-front wavelength (CSS px) and center projection for the static crest. */
  lambda: number;
  projC: number;
  /** Per-instance seeded time offset — multiple instances desync deterministically. */
  timeOffset: number;
  noiseSeed: number;
  springsIdle: boolean;
  // Pointer state, CSS px / px-per-second.
  pointerX: number;
  pointerY: number;
  prevX: number;
  prevY: number;
  velTX: number;
  velTY: number;
  velSX: number;
  velSY: number;
  ease: number;
  easeTarget: number;
  hasPointer: boolean;
  lastMoveT: number;
  lastElapsed: number;
  /** Ghost cursor is currently driving the pointer state. */
  ghostOn: boolean;
  /** Ghost path clock (s) — advances only while the ghost drives. */
  ghostT: number;
  /** Ghost wall clock (s) that paces the swipe surges. */
  ghostWall: number;
}

/**
 * Idle autoplay: when `want` (no real pointer for GHOST_DELAY_MS), a seeded
 * ghost drives the pointer state along a Lissajous figure-eight whose pace
 * surges every few seconds into a swipe, so the springs keep getting kicked.
 */
function driveGhost(it: Internals, tn: Tuning, dt: number, want: boolean) {
  if (!(want && tn.idle && tn.interactive)) {
    if (it.ghostOn) {
      it.ghostOn = false;
      it.hasPointer = false;
      it.easeTarget = 0;
    }
    return;
  }
  // Pace surges periodically: a lazy drift that every ~8s breaks into a swipe.
  it.ghostWall += dt;
  const surge = Math.pow(Math.max(Math.sin(it.ghostWall * 0.8 + it.timeOffset), 0), 4);
  it.ghostT += dt * (0.6 + 3.4 * surge) * Math.max(tn.speed, 0.2);
  const g = it.ghostT + it.timeOffset * 7;
  const x = it.w * (0.5 + 0.4 * Math.sin(g * 0.61) + 0.06 * Math.sin(g * 1.7));
  const y = it.h * (0.5 + 0.3 * Math.sin(g * 1.22 + 0.9));
  if (!it.ghostOn) {
    // Fade the ghost in from wherever its path is — never a streak from (0,0).
    it.ghostOn = true;
    it.prevX = it.pointerX = x;
    it.prevY = it.pointerY = y;
    it.velTX = it.velTY = 0;
    it.hasPointer = true;
    it.easeTarget = GHOST_PRESENCE;
    return;
  }
  const inv = 1 / Math.max(dt, 1e-3);
  it.velTX = it.velTX * 0.4 + (x - it.pointerX) * inv * 0.6;
  it.velTY = it.velTY * 0.4 + (y - it.pointerY) * inv * 0.6;
  it.pointerX = x;
  it.pointerY = y;
  it.hasPointer = true;
  it.easeTarget = GHOST_PRESENCE;
}

/** Advance pointer easing + springs by dt seconds (frame-rate independent). */
function physics(it: Internals, tn: Tuning, dt: number) {
  // Eased 0→1 influence scalar on enter/leave; exponential time-constant blends.
  it.ease += (it.easeTarget - it.ease) * (1 - Math.exp(-dt / EASE_TAU));
  const velK = 1 - Math.exp(-dt / VEL_TAU);
  it.velSX += (it.velTX - it.velSX) * velK;
  it.velSY += (it.velTY - it.velSY) * velK;
  // Target decays between move events so a parked cursor goes quiet.
  const decay = Math.exp(-dt / VEL_DECAY_TAU);
  it.velTX *= decay;
  it.velTY *= decay;

  const speedSm = Math.hypot(it.velSX, it.velSY);
  const drive = tn.interactive ? it.ease * Math.min(speedSm, MAX_POINTER_SPEED) : 0;
  const injecting = drive > 24 && it.hasPointer;

  const { bx, by, sx, sy, vx, vy } = it;
  const n = bx.length;

  if (injecting) {
    it.springsIdle = false;
    // Impulse along the travel direction, ∝ smoothed cursor speed, over dt.
    const invSpeed = 1 / (speedSm || 1);
    let impX = it.velSX * invSpeed * drive * INJECT * dt;
    let impY = it.velSY * invSpeed * drive * INJECT * dt;
    if (it.horizontal) impY *= TRANSVERSE_BOOST;
    else impX *= TRANSVERSE_BOOST;

    // Kick every point near the pointer's travel *segment* this frame, so a
    // fast swipe wakes the whole corridor it crossed — not just event samples.
    const ax = it.prevX;
    const ay = it.prevY;
    const abx = it.pointerX - ax;
    const aby = it.pointerY - ay;
    const ab2 = abx * abx + aby * aby || 1;
    const maxD2 = POINTER_RADIUS * POINTER_RADIUS * 2.9;
    const sigma = POINTER_RADIUS * 0.55;
    const invTwoSig2 = 1 / (2 * sigma * sigma);

    for (let i = 0; i < n; i++) {
      const pxp = bx[i] + sx[i];
      const pyp = by[i] + sy[i];
      let tSeg = ((pxp - ax) * abx + (pyp - ay) * aby) / ab2;
      tSeg = tSeg < 0 ? 0 : tSeg > 1 ? 1 : tSeg;
      const dx = pxp - (ax + abx * tSeg);
      const dy = pyp - (ay + aby * tSeg);
      const d2 = dx * dx + dy * dy;
      if (d2 > maxD2) continue;
      const wgt = Math.exp(-d2 * invTwoSig2);
      vx[i] += impX * wgt;
      vy[i] += impY * wgt;
    }
  }

  if (!it.springsIdle) {
    // Damped Hooke springs, semi-implicit Euler; friction is a per-frame
    // retention at 60fps raised to dt·60 so the feel survives any frame rate.
    const fr = Math.pow(Math.min(Math.max(tn.friction, 0), 0.999), dt * 60);
    const k = Math.min(Math.max(tn.tension, 1), 600);
    const cap = Math.max(tn.maxStretch, 1) * 2.5;
    const cap2 = cap * cap;
    let maxAbs = 0;
    for (let i = 0; i < n; i++) {
      let vxi = (vx[i] - sx[i] * k * dt) * fr;
      let vyi = (vy[i] - sy[i] * k * dt) * fr;
      let sxi = sx[i] + vxi * dt;
      let syi = sy[i] + vyi * dt;
      const r2 = sxi * sxi + syi * syi;
      if (r2 > cap2) {
        const s = cap / Math.sqrt(r2);
        sxi *= s;
        syi *= s;
        vxi *= 0.7;
        vyi *= 0.7;
      }
      vx[i] = vxi;
      vy[i] = vyi;
      sx[i] = sxi;
      sy[i] = syi;
      const a = Math.abs(sxi) + Math.abs(syi) + (Math.abs(vxi) + Math.abs(vyi)) * 0.05;
      if (a > maxAbs) maxAbs = a;
    }
    // All quiet → zero the field and skip this loop entirely until re-kicked.
    if (!injecting && maxAbs < 0.06) {
      it.springsIdle = true;
      sx.fill(0);
      sy.fill(0);
      vx.fill(0);
      vy.fill(0);
    }
  }

  it.prevX = it.pointerX;
  it.prevY = it.pointerY;
}

/** One frame. `staticFrame` parks the swell crest mid-frame — visible curvature. */
function draw(it: Internals, tn: Tuning, t: number, staticFrame: boolean) {
  const { ctx, w, h, horizontal, lines, bx, by, sx, sy, px, py, lambda } = it;
  ctx.fillStyle = `rgb(${BG[0]},${BG[1]},${BG[2]})`;
  ctx.fillRect(0, 0, w, h);
  ctx.lineJoin = "round";
  ctx.lineCap = "round";

  const baseT = t * Math.max(tn.speed, 0) + it.timeOffset;
  const swellT = baseT / Math.max(tn.swellInterval, 0.5);
  // Live: front travels with time. Static: phase chosen so the crest sits at
  // the frame center — the frozen lattice always shows a full rolling swell.
  const phase0 = staticFrame ? (TAU * it.projC) / lambda - Math.PI / 2 : TAU * swellT;
  const springs = !staticFrame && !it.springsIdle;
  const maxStretch = Math.max(tn.maxStretch, 1);
  const invF = 1 / FEATURE_PX;
  const amplitude = Math.max(tn.amplitude, 0);
  const intensity = Math.max(tn.intensity, 0);
  // Transverse-dominant orbits: lines sway across their own axis more than along it.
  const axX = horizontal ? 0.6 : 1;
  const axY = horizontal ? 1 : 0.6;

  for (const line of lines) {
    const alpha = Math.min(line.alphaBase * intensity, 1);
    if (alpha < 0.01) continue;
    const lt = staticFrame ? NOISE_T_STATIC + line.tPhase : baseT * line.tRate + line.tPhase;
    const ampBase = amplitude * (0.55 + 0.45 * line.z);
    const end = line.start + line.count;

    let j = 0;
    for (let i = line.start; i < end; i++, j++) {
      const x = bx[i];
      const y = by[i];
      // Slightly diagonal front direction so the swell reads organic, not axial.
      const proj = horizontal ? x + y * 0.28 : y + x * 0.28;
      // Near rows lead the front by a small phase — depth parallax in time too.
      const ph = (TAU * proj) / lambda - phase0 + line.z * 0.55;
      const swell = Math.sin(ph);
      // Noise sample used as an ANGLE through (cos, sin) — orbital, woven motion.
      const angle =
        fbm2(x * invF, y * invF, lt, it.noiseSeed) * TAU * ANGLE_TURNS + SWELL_ANGLE * swell;
      const amp = ampBase * (1 + SWELL_AMP * swell);
      let ox = Math.cos(angle) * amp * axX;
      let oy = Math.sin(angle) * amp * axY;
      if (springs) {
        const sxi = sx[i];
        const syi = sy[i];
        const r = Math.hypot(sxi, syi);
        if (r > 0.001) {
          // tanh soft-clip: stretch saturates toward maxStretch, never hard-stops.
          const soft = (maxStretch * Math.tanh(r / maxStretch)) / r;
          ox += sxi * soft;
          oy += syi * soft;
        }
      }
      px[j] = x + ox;
      py[j] = y + oy;
    }

    ctx.strokeStyle = `rgba(${line.rgb[0]},${line.rgb[1]},${line.rgb[2]},${alpha})`;
    ctx.lineWidth = line.width;
    ctx.beginPath();
    ctx.moveTo(px[0], py[0]);
    const cnt = line.count;
    for (let m = 1; m < cnt - 1; m++) {
      ctx.quadraticCurveTo(px[m], py[m], (px[m] + px[m + 1]) * 0.5, (py[m] + py[m + 1]) * 0.5);
    }
    ctx.lineTo(px[cnt - 1], py[cnt - 1]);
    ctx.stroke();
  }
}

/**
 * Swell — a sprung wave lattice. Rows of smooth lines ride an orbital noise
 * field (each point circles its anchor, so the mesh moves with woven, curtain-
 * like coherence) while a slow autonomous swell front rolls through every ~8s,
 * so the field breathes with no cursor. Swipe through it and points take
 * velocity impulses along your travel direction, proportional to cursor speed,
 * then spring home on damped Hooke springs — fast flicks send a wobble down
 * the lattice. Depth-graded rows (opacity, width, drift rate) give a parallax
 * curtain; deep-sea steel by default. Canvas 2D: seeded and SSR-safe, paused
 * offscreen and on hidden tabs, DPR-clamped with device-pixel-aligned strokes,
 * reduced-motion-safe (a static frame with the swell crest mid-roll). Left
 * untouched, a seeded ghost cursor takes over (`idle`). Drop it inside any
 * `relative` container.
 */
export function Swell({
  orientation = "horizontal",
  gap = { x: 28, y: 26 },
  amplitude = 16,
  speed = 1,
  intensity = 1,
  tension = DEFAULT_SPRING.tension,
  friction = DEFAULT_SPRING.friction,
  maxStretch = DEFAULT_SPRING.maxStretch,
  swellInterval = 8,
  colors = DEFAULT_COLORS,
  interactive = true,
  idle = true,
  seed = 1,
  dpr = 2,
  paused = false,
  className,
  style,
  ...props
}: SwellProps) {
  const canvasRef = React.useRef<HTMLCanvasElement>(null);
  const internalsRef = React.useRef<Internals | null>(null);
  const reducedMotion = useReducedMotion();

  const frozen = paused || reducedMotion;
  const frozenRef = React.useRef(frozen);
  frozenRef.current = frozen;
  // performance.now() of the last real pointer move; -Infinity = never.
  const lastRealRef = React.useRef(-Infinity);

  // Tuning knobs mutate in place — no lattice rebuild on feel changes.
  const tuningRef = React.useRef<Tuning>({
    speed,
    intensity,
    amplitude,
    tension,
    friction,
    maxStretch,
    swellInterval,
    interactive,
    idle,
  });
  tuningRef.current = {
    speed,
    intensity,
    amplitude,
    tension,
    friction,
    maxStretch,
    swellInterval,
    interactive,
    idle,
  };

  const gapKey = `${gap.x},${gap.y}`;
  const colorsKey = colors.join(",");

  const tick = React.useCallback((elapsed: number) => {
    const it = internalsRef.current;
    if (!it) return;
    const tn = tuningRef.current;
    const dt = Math.min(Math.max(elapsed - it.lastElapsed, 1 / 240), 1 / 30);
    it.lastElapsed = elapsed;
    driveGhost(it, tn, dt, performance.now() - lastRealRef.current > GHOST_DELAY_MS);
    physics(it, tn, dt);
    draw(it, tn, elapsed, false);
  }, []);

  const containerRef = useVisibilityPause<HTMLDivElement>(tick, { paused: frozen });

  // Build the lattice; rebuild on size / orientation / spacing / palette / seed.
  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 dprScale = Math.min(
      typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1,
      Math.min(Math.max(dpr, 1), 2)
    );
    const farRgb = hexToRgb(colors[0]);
    const nearRgb = hexToRgb(colors[1]);
    const horizontal = orientation !== "vertical";
    const gapX = Math.min(Math.max(gap.x, 8), 200);
    const gapY = Math.min(Math.max(gap.y, 8), 200);
    /** Half-pixel alignment in DEVICE px so idle 1px strokes sit crisp on any DPR. */
    const alignHalf = (v: number) => (Math.round(v * dprScale - 0.5) + 0.5) / dprScale;

    const onContextLost = (e: Event) => e.preventDefault();
    canvas.addEventListener("contextlost", onContextLost);

    const build = () => {
      const w = Math.max(container.clientWidth, 1);
      const h = Math.max(container.clientHeight, 1);
      canvas.width = Math.round(w * dprScale);
      canvas.height = Math.round(h * dprScale);
      canvas.style.width = "100%";
      canvas.style.height = "100%";
      ctx.setTransform(dprScale, 0, 0, dprScale, 0, 0);

      const rand = mulberry32((Math.round(seed || 1) * 0x9e3779b1) >>> 0 || 1);

      // Row decimation on short stages: skip every other row below 480px height.
      const dec = h < 480 ? 2 : 1;
      const lineGap = horizontal ? gapY * dec : gapX;
      let step = horizontal ? gapX : gapY * dec;
      const span = horizontal ? h : w; // across the lines
      const length = horizontal ? w : h; // along the lines
      const lineCount = Math.max(2, Math.floor(span / lineGap));
      let ptsPerLine = Math.max(4, Math.floor(length / step) + 3);
      while (lineCount * ptsPerLine > MAX_POINTS) {
        step *= 1.5;
        ptsPerLine = Math.max(4, Math.floor(length / step) + 3);
      }
      const n = lineCount * ptsPerLine;

      const bx = new Float32Array(n);
      const by = new Float32Array(n);
      const lines: Line[] = [];
      for (let li = 0; li < lineCount; li++) {
        const cross = alignHalf((li + 0.5) * lineGap);
        // Horizontal rows: depth rises toward the bottom (sea toward the viewer).
        // Vertical curtains: seeded random depth layers.
        const z = horizontal
          ? Math.min(1, Math.max(0, 0.78 * (cross / h) + 0.22 * rand()))
          : rand();
        const start = li * ptsPerLine;
        for (let j = 0; j < ptsPerLine; j++) {
          const along = (j - 1) * step; // one point of overhang each side
          bx[start + j] = horizontal ? along : cross;
          by[start + j] = horizontal ? cross : along;
        }
        const zz = Math.pow(z, 1.15);
        lines.push({
          start,
          count: ptsPerLine,
          z,
          rgb: [
            Math.round(farRgb[0] + (nearRgb[0] - farRgb[0]) * zz),
            Math.round(farRgb[1] + (nearRgb[1] - farRgb[1]) * zz),
            Math.round(farRgb[2] + (nearRgb[2] - farRgb[2]) * zz),
          ],
          alphaBase: 0.34 + 0.54 * zz,
          width: 0.75 + 0.9 * z,
          tRate: 0.55 + 0.6 * z,
          tPhase: rand() * 61,
        });
      }
      // Far rows first so near rows draw on top.
      lines.sort((a, b) => a.z - b.z);

      const it: Internals = {
        ctx,
        w,
        h,
        horizontal,
        lines,
        bx,
        by,
        sx: new Float32Array(n),
        sy: new Float32Array(n),
        vx: new Float32Array(n),
        vy: new Float32Array(n),
        px: new Float32Array(ptsPerLine),
        py: new Float32Array(ptsPerLine),
        lambda: Math.max(w, h) * 0.8,
        projC: horizontal ? w / 2 + h * 0.14 : h / 2 + w * 0.14,
        timeOffset: rand() * 100,
        noiseSeed: (Math.round(seed || 1) * 0x51ab7) >>> 0 || 1,
        springsIdle: true,
        pointerX: 0,
        pointerY: 0,
        prevX: 0,
        prevY: 0,
        velTX: 0,
        velTY: 0,
        velSX: 0,
        velSY: 0,
        ease: 0,
        easeTarget: 0,
        hasPointer: false,
        lastMoveT: 0,
        lastElapsed: 0,
        ghostOn: false,
        ghostT: 0,
        ghostWall: 0,
      };
      internalsRef.current = it;
      // Never flash empty — paint one frame immediately.
      draw(it, tuningRef.current, 0, frozenRef.current);
    };

    const onPointerMove = (e: PointerEvent) => {
      const it = internalsRef.current;
      if (!it || frozenRef.current || !tuningRef.current.interactive) return;
      const rect = container.getBoundingClientRect();
      const x = e.clientX - rect.left;
      const y = e.clientY - rect.top;
      const now = performance.now();
      lastRealRef.current = now;
      if (it.ghostOn) {
        // Real pointer takes over instantly: drop the ghost's velocity so the
        // jump from its position to the cursor never registers as a swipe.
        it.ghostOn = false;
        it.hasPointer = false;
        it.velTX = it.velTY = 0;
      }
      if (it.hasPointer) {
        const dtE = Math.min(Math.max((now - it.lastMoveT) / 1000, 0.004), 0.05);
        // Blend event velocity into the target to tame event-timing jitter.
        it.velTX = it.velTX * 0.4 + ((x - it.pointerX) / dtE) * 0.6;
        it.velTY = it.velTY * 0.4 + ((y - it.pointerY) / dtE) * 0.6;
      } else {
        it.prevX = x;
        it.prevY = y;
      }
      it.pointerX = x;
      it.pointerY = y;
      it.lastMoveT = now;
      it.hasPointer = true;
      it.easeTarget = 1;
    };

    const onPointerLeave = () => {
      const it = internalsRef.current;
      if (!it) return;
      it.easeTarget = 0;
      it.hasPointer = false;
      // The ghost resumes after the usual delay rather than snapping in.
      lastRealRef.current = performance.now();
    };

    container.addEventListener("pointermove", onPointerMove);
    container.addEventListener("pointerleave", onPointerLeave);
    container.addEventListener("pointercancel", onPointerLeave);

    const ro = new ResizeObserver(build);
    ro.observe(container);
    build();

    return () => {
      ro.disconnect();
      container.removeEventListener("pointermove", onPointerMove);
      container.removeEventListener("pointerleave", onPointerLeave);
      container.removeEventListener("pointercancel", onPointerLeave);
      canvas.removeEventListener("contextlost", onContextLost);
      internalsRef.current = null;
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [orientation, gapKey, colorsKey, seed, dpr]);

  // Render the designed static frame in place when paused / reduced-motion
  // toggles on; springs are zeroed so unfreezing never jumps.
  React.useEffect(() => {
    const it = internalsRef.current;
    if (!it || !frozen) return;
    it.springsIdle = true;
    it.sx.fill(0);
    it.sy.fill(0);
    it.vx.fill(0);
    it.vy.fill(0);
    it.ease = 0;
    it.easeTarget = 0;
    it.velTX = 0;
    it.velTY = 0;
    it.velSX = 0;
    it.velSY = 0;
    it.hasPointer = false;
    it.ghostOn = false;
    draw(it, tuningRef.current, 0, true);
    // Tuning deps: knob changes while frozen must repaint the still frame.
  }, [frozen, speed, intensity, amplitude, swellInterval]);

  return (
    <div
      ref={containerRef}
      aria-hidden
      data-crucible="swell"
      className={cn("absolute inset-0 -z-10 overflow-hidden", className)}
      style={style}
      {...props}
    >
      <canvas ref={canvasRef} className="block h-full w-full" />
      {/* Soft vignette so the lattice sinks into the frame edges. */}
      <div
        className="pointer-events-none absolute inset-0"
        style={{
          background:
            "radial-gradient(120% 120% at 50% 42%, transparent 55%, rgba(0,0,0,0.5) 100%)",
        }}
      />
    </div>
  );
}
```
