Swell
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).
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";
/**
* 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;
/** 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;
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;
}
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;
}
/** 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). 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,
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;
// Tuning knobs mutate in place — no lattice rebuild on feel changes.
const tuningRef = React.useRef<Tuning>({
speed,
intensity,
amplitude,
tension,
friction,
maxStretch,
swellInterval,
interactive,
});
tuningRef.current = {
speed,
intensity,
amplitude,
tension,
friction,
maxStretch,
swellInterval,
interactive,
};
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;
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,
};
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();
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;
};
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;
draw(it, tuningRef.current, 0, true);
}, [frozen]);
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>
);
}
Installation
CLI
npx shadcn@latest add @crucible/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. |
| 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 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.