A canvas lattice of dots and lines that bends toward the pointer like a magnetic field, with computed per-node displacement.
npx shadcn@latest add @paragon/magnetic-grid"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* MagneticGrid — a canvas field of dots (optionally joined by lines) that bend
* toward the pointer like iron filings around a magnet. Each node's displacement
* is computed from its distance to the pointer with a smooth falloff, so the
* whole lattice warps and settles.
*
* Raw Canvas 2D. Pauses offscreen (IntersectionObserver), cleans up rAF /
* observers / listeners on unmount, and freezes to the rest lattice under
* `prefers-reduced-motion` (or `static`). Pointer interaction is gated to fine
* pointers; on touch it renders the static grid. Decorative — `aria-hidden`.
*/
export interface MagneticGridProps extends React.ComponentProps<"div"> {
/** Node / line color. Accepts any CSS color or token. */
color?: string;
/** Pull strength (max px a node moves toward the pointer). */
strength?: number;
/** Grid spacing in px. */
spacing?: number;
/** Influence radius in px. */
radius?: number;
/** Draw connecting lines between neighbors. */
lines?: boolean;
/** Freeze to the rest lattice. */
static?: boolean;
}
interface Node {
ox: number;
oy: number;
x: number;
y: number;
}
export function MagneticGrid({
color = "var(--color-primary)",
strength = 22,
spacing = 34,
radius = 130,
lines = true,
static: isStatic = false,
className,
...props
}: MagneticGridProps) {
const wrapRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
React.useEffect(() => {
const wrap = wrapRef.current;
const canvas = canvasRef.current;
if (!wrap || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let width = 0;
let height = 0;
let cols = 0;
let rows = 0;
let nodes: Node[] = [];
const pointer = { x: -9999, y: -9999, active: false };
const fine = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
const resolveColor = () => {
const probe = document.createElement("span");
probe.style.color = color;
document.body.appendChild(probe);
const c = getComputedStyle(probe).color;
probe.remove();
return c;
};
let strokeColor = resolveColor();
const build = () => {
cols = Math.floor(width / spacing) + 2;
rows = Math.floor(height / spacing) + 2;
const offX = (width - (cols - 1) * spacing) / 2;
const offY = (height - (rows - 1) * spacing) / 2;
nodes = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const ox = offX + c * spacing;
const oy = offY + r * spacing;
nodes.push({ ox, oy, x: ox, y: oy });
}
}
};
const rad2 = () => radius * radius;
const step = () => {
const r2 = rad2();
for (const n of nodes) {
let tx = n.ox;
let ty = n.oy;
if (pointer.active) {
const dx = pointer.x - n.ox;
const dy = pointer.y - n.oy;
const d2 = dx * dx + dy * dy;
if (d2 < r2) {
const d = Math.sqrt(d2) || 1;
const f = (1 - d / radius) ** 2;
tx += (dx / d) * strength * f;
ty += (dy / d) * strength * f;
}
}
n.x += (tx - n.x) * 0.18;
n.y += (ty - n.y) * 0.18;
}
};
const draw = () => {
ctx.clearRect(0, 0, width, height);
if (lines) {
ctx.strokeStyle = strokeColor;
ctx.globalAlpha = 0.28;
ctx.lineWidth = 1;
ctx.beginPath();
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const i = r * cols + c;
const n = nodes[i];
if (c < cols - 1) {
const right = nodes[i + 1];
ctx.moveTo(n.x, n.y);
ctx.lineTo(right.x, right.y);
}
if (r < rows - 1) {
const down = nodes[i + cols];
ctx.moveTo(n.x, n.y);
ctx.lineTo(down.x, down.y);
}
}
}
ctx.stroke();
}
ctx.globalAlpha = 0.85;
ctx.fillStyle = strokeColor;
for (const n of nodes) {
const dx = pointer.active ? pointer.x - n.x : 0;
const dy = pointer.active ? pointer.y - n.y : 0;
const near = pointer.active
? Math.max(0, 1 - Math.hypot(dx, dy) / radius)
: 0;
const size = 1 + near * 1.6;
ctx.beginPath();
ctx.arc(n.x, n.y, size, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
};
let rafId: number | null = null;
let last = 0;
const TICK = 1000 / 50;
const loop = (now: number) => {
rafId = requestAnimationFrame(loop);
if (now - last < TICK) return;
last = now;
step();
draw();
};
let running = false;
let inView = false;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
const rest = () => {
for (const n of nodes) {
n.x = n.ox;
n.y = n.oy;
}
draw();
};
const sync = () => {
const run = inView && !isStatic && !reduce.matches;
if (run && !running) {
running = true;
last = performance.now();
rafId = requestAnimationFrame(loop);
} else if (!run && running) {
running = false;
if (rafId !== null) cancelAnimationFrame(rafId);
rafId = null;
rest();
} else if (!run) {
rest();
}
};
const resize = () => {
const rect = wrap.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
width = rect.width;
height = rect.height;
canvas.width = Math.max(1, Math.round(width * dpr));
canvas.height = Math.max(1, Math.round(height * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
strokeColor = resolveColor();
build();
if (!running) rest();
};
const onMove = (e: PointerEvent) => {
if (!fine) return;
const rect = wrap.getBoundingClientRect();
pointer.x = e.clientX - rect.left;
pointer.y = e.clientY - rect.top;
pointer.active = true;
};
const onLeave = () => {
pointer.active = false;
pointer.x = -9999;
pointer.y = -9999;
};
const ro = new ResizeObserver(resize);
ro.observe(wrap);
const io = new IntersectionObserver(([e]) => {
inView = e?.isIntersecting ?? false;
sync();
});
io.observe(wrap);
reduce.addEventListener("change", sync);
wrap.addEventListener("pointermove", onMove);
wrap.addEventListener("pointerleave", onLeave);
return () => {
ro.disconnect();
io.disconnect();
reduce.removeEventListener("change", sync);
wrap.removeEventListener("pointermove", onMove);
wrap.removeEventListener("pointerleave", onLeave);
if (rafId !== null) cancelAnimationFrame(rafId);
};
}, [color, strength, spacing, radius, lines, isStatic]);
return (
<div
ref={wrapRef}
aria-hidden
data-slot="magnetic-grid"
className={cn("pointer-events-auto absolute inset-0 overflow-hidden", className)}
{...props}
>
<canvas ref={canvasRef} className="size-full" />
</div>
);
}