Backgrounds

Magnetic Grid

A canvas lattice of dots and lines that bends toward the pointer like a magnetic field, with computed per-node displacement.

Install

npx shadcn@latest add @paragon/magnetic-grid

magnetic-grid.tsx

"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>
  );
}