"use client";
import * as React from "react";
export type WarpGridMode = "attract" | "repel";
export interface WarpGridProps extends React.ComponentPropsWithoutRef<"div"> {
/** Distance between grid lines, in px. */
spacing?: number;
/** How far the grid bends toward (or away from) the pointer, 0–1. */
strength?: number;
/** Pointer influence radius, in px. */
radius?: number;
/** Pull the lines in like a lens, or push them away. */
mode?: WarpGridMode;
/** Send a soft ripple through the grid every few seconds. */
ripples?: boolean;
/** Colour of the lines near the pointer (any CSS colour). */
accent?: string;
/** Grid line width, in px. */
lineWidth?: number;
}
type RGB = [number, number, number];
/* ---------------------------------- Colour --------------------------------- */
let probe: CanvasRenderingContext2D | null = null;
/** Resolves any CSS colour (hex, rgb(), oklch()…) to RGB by painting it on a 1×1 canvas. */
function resolveColor(input: string, fallback: RGB): RGB {
probe ??= document.createElement("canvas").getContext("2d", { willReadFrequently: true });
if (!probe) return fallback;
probe.clearRect(0, 0, 1, 1);
probe.fillStyle = "rgba(0, 0, 0, 0)";
probe.fillStyle = input; // invalid colours are ignored → transparent → fallback
probe.fillRect(0, 0, 1, 1);
const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;
return a === 0 ? fallback : [r, g, b];
}
const luminance = ([r, g, b]: RGB) => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
/** On light themes (dark ink), very light accents are pulled toward the ink so they stay visible. */
function legible(color: RGB, ink: RGB): RGB {
if (luminance(ink) > 0.5 || luminance(color) < 0.62) return color;
return color.map((v, i) => Math.round(v + (ink[i] - v) * 0.45)) as RGB;
}
/** Re-reads colours when the theme changes: class/data-theme on <html> or a scoped [data-theme] ancestor. */
function observeTheme(el: Element, onChange: () => void) {
const mo = new MutationObserver(onChange);
const options = { attributes: true, attributeFilter: ["class", "data-theme"] };
mo.observe(document.documentElement, options);
const scoped = el.parentElement?.closest("[data-theme]");
if (scoped && scoped !== document.documentElement) mo.observe(scoped, options);
const mq = window.matchMedia("(prefers-color-scheme: dark)");
mq.addEventListener("change", onChange);
return () => {
mo.disconnect();
mq.removeEventListener("change", onChange);
};
}
/* ----------------------------- Reduced motion ------------------------------ */
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/* -------------------------------- Component -------------------------------- */
const STEP_MS = 1000 / 60;
const SPRING = 0.14; // pull toward the target displacement per step
const DAMPING = 0.78; // velocity kept per step: < 1 settles, with a little wobble on release
const RIPPLE_SPEED = 300; // px per second
const RIPPLE_LIFE = 2.4; // seconds
const RIPPLE_AMPLITUDE = 9; // px
const IDLE_RIPPLE_MS = 3600;
/**
* A line grid that bends around the pointer like a gravitational lens. Every intersection is a
* damped spring chasing a target displacement (a smooth falloff toward or away from the pointer,
* plus any passing ripples), so the grid eases in, wobbles slightly and springs back when the
* pointer leaves. Rows and columns are stroked as polylines; a second pass with a radial
* gradient tints the lines near the pointer. The loop stops once every node has settled.
*/
export function WarpGrid({
spacing = 36,
strength = 0.5,
radius = 180,
mode = "attract",
ripples = true,
accent = "#FF7448",
lineWidth = 1,
className = "",
children,
...props
}: WarpGridProps) {
const reduceMotion = usePrefersReducedMotion();
const canvasRef = React.useRef<HTMLCanvasElement>(null);
React.useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return;
const gap = Math.max(8, spacing);
const reach = Math.max(20, radius);
const pull = Math.min(1, Math.max(0, strength));
let width = 0;
let height = 0;
let dpr = 1;
let cols = 0;
let rows = 0;
let x0 = 0;
let y0 = 0;
let raf = 0;
let sleep = 0;
let last = 0;
let pending = 0;
// Result of the most recent physics step. On >60Hz displays a frame can be shorter than
// one step, so "no step ran this frame" must not be mistaken for "everything settled".
let lastMoving = true;
let inView = false;
let idleClock = IDLE_RIPPLE_MS * 0.4;
let ink = "rgba(24, 24, 27, 0.14)";
let accentRGB: RGB = [255, 116, 72];
// Per-node displacement and velocity (flat typed arrays, index = row * cols + col).
let ox = new Float32Array(0);
let oy = new Float32Array(0);
let vx = new Float32Array(0);
let vy = new Float32Array(0);
const ptr = { clientX: 0, clientY: 0, x: 0, y: 0, inside: false, strength: 0 };
let waves: { x: number; y: number; age: number }[] = [];
const readTheme = () => {
const base = resolveColor(getComputedStyle(canvas).color, [24, 24, 27]);
const dark = luminance(base) > 0.5;
ink = `rgba(${base[0]}, ${base[1]}, ${base[2]}, ${dark ? 0.16 : 0.15})`;
accentRGB = legible(resolveColor(accent, [255, 116, 72]), base);
};
const layout = () => {
// One extra line beyond each edge, so displaced edges never reveal a gap.
cols = Math.ceil(width / gap) + 3;
rows = Math.ceil(height / gap) + 3;
x0 = (width - (cols - 1) * gap) / 2;
y0 = (height - (rows - 1) * gap) / 2;
const n = cols * rows;
ox = new Float32Array(n);
oy = new Float32Array(n);
vx = new Float32Array(n);
vy = new Float32Array(n);
};
/** One fixed 60Hz step. Returns true while anything is still moving. */
const step = () => {
const s = ptr.strength;
const r2 = reach * reach;
let moving = false;
for (let j = 0; j < rows; j++) {
const by = y0 + j * gap;
for (let i = 0; i < cols; i++) {
const k = j * cols + i;
const bx = x0 + i * gap;
let tx = 0;
let ty = 0;
if (s > 0.001) {
const dx = bx - ptr.x;
const dy = by - ptr.y;
const d2 = dx * dx + dy * dy;
if (d2 < r2 && d2 > 0.01) {
const d = Math.sqrt(d2);
const u = 1 - d2 / r2;
const f = u * u * pull * s;
// Attract scales with distance (a lens pinch that never crosses the pointer);
// repel pushes by a share of the radius. Both stay monotonic, so lines never fold.
const m = mode === "attract" ? -f * d * 0.55 : f * reach * 0.28;
tx += (dx / d) * m;
ty += (dy / d) * m;
}
}
for (const w of waves) {
const dx = bx - w.x;
const dy = by - w.y;
const d = Math.sqrt(dx * dx + dy * dy) || 1;
const ring = d - w.age * RIPPLE_SPEED;
if (ring > -90 && ring < 90) {
const m = RIPPLE_AMPLITUDE * (1 - w.age / RIPPLE_LIFE) * Math.exp(-(ring * ring) / 1600) * Math.sin(ring * 0.05);
tx += (dx / d) * m;
ty += (dy / d) * m;
}
}
vx[k] = (vx[k] + (tx - ox[k]) * SPRING) * DAMPING;
vy[k] = (vy[k] + (ty - oy[k]) * SPRING) * DAMPING;
ox[k] += vx[k];
oy[k] += vy[k];
if (!moving && (Math.abs(vx[k]) > 0.01 || Math.abs(vy[k]) > 0.01 || Math.abs(tx - ox[k]) > 0.05 || Math.abs(ty - oy[k]) > 0.05)) {
moving = true;
}
}
}
return moving;
};
const buildPath = () => {
const path = new Path2D();
for (let j = 0; j < rows; j++) {
const by = y0 + j * gap;
for (let i = 0; i < cols; i++) {
const k = j * cols + i;
const x = x0 + i * gap + ox[k];
const y = by + oy[k];
if (i === 0) path.moveTo(x, y);
else path.lineTo(x, y);
}
}
for (let i = 0; i < cols; i++) {
const bx = x0 + i * gap;
for (let j = 0; j < rows; j++) {
const k = j * cols + i;
const x = bx + ox[k];
const y = y0 + j * gap + oy[k];
if (j === 0) path.moveTo(x, y);
else path.lineTo(x, y);
}
}
return path;
};
const draw = () => {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
const path = buildPath();
ctx.lineWidth = lineWidth;
ctx.lineJoin = "round";
ctx.strokeStyle = ink;
ctx.stroke(path);
if (ptr.strength > 0.02) {
// Accent pass: the same lines, tinted by a radial gradient centred on the pointer.
const [r, g, b] = accentRGB;
const glow = ctx.createRadialGradient(ptr.x, ptr.y, 0, ptr.x, ptr.y, reach);
glow.addColorStop(0, `rgba(${r}, ${g}, ${b}, ${(0.95 * ptr.strength).toFixed(3)})`);
glow.addColorStop(0.55, `rgba(${r}, ${g}, ${b}, ${(0.35 * ptr.strength).toFixed(3)})`);
glow.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`);
ctx.strokeStyle = glow;
ctx.lineWidth = lineWidth + 0.25;
ctx.stroke(path);
}
};
const frame = (now: number) => {
raf = 0;
const dt = Math.min(64, last ? now - last : STEP_MS);
last = now;
if (ptr.inside) {
const rect = canvas.getBoundingClientRect();
ptr.x = ptr.clientX - rect.left;
ptr.y = ptr.clientY - rect.top;
}
ptr.strength += ((ptr.inside ? 1 : 0) - ptr.strength) * (1 - Math.pow(0.86, dt / STEP_MS));
if (!ptr.inside && ptr.strength < 0.005) ptr.strength = 0;
if (ripples) {
idleClock += dt;
if (idleClock >= IDLE_RIPPLE_MS) {
idleClock = 0;
waves.push({ x: width * (0.15 + Math.random() * 0.7), y: height * (0.2 + Math.random() * 0.6), age: 0 });
}
}
for (const w of waves) w.age += dt / 1000;
waves = waves.filter((w) => w.age < RIPPLE_LIFE);
// Fixed 60Hz steps keep the spring identical on 60, 120 or 144Hz displays.
pending = Math.min(pending + dt, STEP_MS * 4);
let stepped = false;
let moving = false;
while (pending >= STEP_MS) {
moving = step() || moving;
stepped = true;
pending -= STEP_MS;
}
if (stepped) lastMoving = moving;
draw();
if (lastMoving || ptr.inside || ptr.strength > 0 || waves.length > 0) schedule();
else if (ripples) {
// Settled: sleep (no rAF) until the next idle ripple is due.
last = 0;
sleep = window.setTimeout(() => {
sleep = 0;
idleClock = IDLE_RIPPLE_MS;
schedule();
}, IDLE_RIPPLE_MS - idleClock);
}
};
const schedule = () => {
if (sleep) {
clearTimeout(sleep);
sleep = 0;
}
if (!raf && inView && !reduceMotion && !document.hidden) raf = requestAnimationFrame(frame);
};
const stop = () => {
cancelAnimationFrame(raf);
clearTimeout(sleep);
sleep = 0;
raf = 0;
last = 0;
};
const resize = () => {
const rect = canvas.getBoundingClientRect();
if (Math.abs(rect.width - width) < 1 && Math.abs(rect.height - height) < 1 && cols > 0) return;
width = rect.width;
height = rect.height;
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.max(1, Math.round(width * dpr));
canvas.height = Math.max(1, Math.round(height * dpr));
layout();
draw();
schedule();
};
const onThemeChange = () => {
readTheme();
if (!raf) draw();
};
const onPointerMove = (e: PointerEvent) => {
if (e.pointerType !== "mouse" && e.pointerType !== "pen") return;
const rect = canvas.getBoundingClientRect();
ptr.clientX = e.clientX;
ptr.clientY = e.clientY;
const inside = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
if (inside && !ptr.inside && ptr.strength < 0.01) {
ptr.x = e.clientX - rect.left;
ptr.y = e.clientY - rect.top;
}
ptr.inside = inside;
if (inside) schedule();
};
const onPointerDown = (e: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (x < 0 || y < 0 || x > rect.width || y > rect.height) return;
waves.push({ x, y, age: 0 });
if (waves.length > 5) waves.shift();
schedule();
};
const onPointerLeave = () => {
ptr.inside = false;
};
const onVisibility = () => (document.hidden ? stop() : schedule());
// Pointer tracking lives on the window (only while visible), so content layered above the
// grid never blocks it and the grid never blocks the content.
const io = new IntersectionObserver(([entry]) => {
inView = entry.isIntersecting;
if (inView) {
if (!reduceMotion) {
window.addEventListener("pointermove", onPointerMove, { passive: true });
window.addEventListener("pointerdown", onPointerDown, { passive: true });
document.documentElement.addEventListener("pointerleave", onPointerLeave);
}
schedule();
} else {
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerdown", onPointerDown);
document.documentElement.removeEventListener("pointerleave", onPointerLeave);
ptr.inside = false;
stop();
}
});
readTheme();
resize();
const ro = new ResizeObserver(resize);
ro.observe(canvas);
io.observe(canvas);
const unobserveTheme = observeTheme(canvas, onThemeChange);
document.addEventListener("visibilitychange", onVisibility);
return () => {
stop();
ro.disconnect();
io.disconnect();
unobserveTheme();
document.removeEventListener("visibilitychange", onVisibility);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerdown", onPointerDown);
document.documentElement.removeEventListener("pointerleave", onPointerLeave);
};
}, [spacing, strength, radius, mode, ripples, accent, lineWidth, reduceMotion]);
return (
<div className={`absolute inset-0 overflow-hidden ${className}`} {...props}>
<canvas
ref={canvasRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0 block h-full w-full text-zinc-900 dark:text-zinc-100"
/>
{children != null && <div className="relative h-full w-full">{children}</div>}
</div>
);
}