Backgrounds
MagneticGrid
A dot grid. Dots near the pointer push away, grow and light up, then settle back. Idle when nothing moves.
Download
- React
- Tailwind CSS v4
components/ui/magnetic-grid.tsx
"use client";
import { useEffect, useRef, type ReactNode } from "react";
type MagneticGridProps = {
children?: ReactNode;
className?: string;
/** Colour dots light up to near the pointer. */
color?: string;
/** Colour of dots at rest. */
dot?: string;
background?: string;
/** Space between dots in pixels. */
gap?: number;
/** Reach of the pointer in pixels. */
radius?: number;
};
type Dot = { x: number; y: number; ox: number; oy: number; lit: number };
/* MagneticGrid: a dot grid. Dots near the pointer push away, grow and light up, then settle back. */
export function MagneticGrid({
children,
className = "",
color = "#a78bfa",
dot = "rgba(255,255,255,0.18)",
background = "#07060f",
gap = 22,
radius = 120,
}: MagneticGridProps) {
const wrapRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const wrap = wrapRef.current;
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!wrap || !canvas || !ctx) return;
const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const pointer = { x: 0, y: 0, inside: false };
let w = 0;
let h = 0;
let raf = 0;
let last = 0;
let visible = false;
let dots: Dot[] = [];
const draw = () => {
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = dot;
for (const d of dots) {
ctx.beginPath();
ctx.arc(d.x + d.ox, d.y + d.oy, 1.2, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = color;
for (const d of dots) {
if (d.lit < 0.01) continue;
ctx.globalAlpha = d.lit;
ctx.beginPath();
ctx.arc(d.x + d.ox, d.y + d.oy, 1.2 + d.lit * 2.2, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
};
const frame = (now: number) => {
const dt = last ? Math.min((now - last) / 1000, 0.05) : 0;
last = now;
const ease = 1 - Math.pow(0.0005, dt);
let moving = false;
for (const d of dots) {
const dx = d.x - pointer.x;
const dy = d.y - pointer.y;
const dist = Math.hypot(dx, dy) || 1;
const f = pointer.inside && dist < radius ? (1 - dist / radius) ** 2 : 0;
d.ox += ((dx / dist) * f * gap - d.ox) * ease;
d.oy += ((dy / dist) * f * gap - d.oy) * ease;
d.lit += (f - d.lit) * ease;
if (Math.abs(d.ox) + Math.abs(d.oy) > 0.05 || d.lit > 0.005) moving = true;
}
draw();
// Stop when the pointer is gone and every dot has settled.
raf = pointer.inside || moving ? requestAnimationFrame(frame) : 0;
};
const wake = () => {
if (still || raf || !visible) return;
last = 0;
raf = requestAnimationFrame(frame);
};
const onMove = (e: PointerEvent) => {
const r = canvas.getBoundingClientRect();
pointer.x = e.clientX - r.left;
pointer.y = e.clientY - r.top;
pointer.inside = true;
wake();
};
const onLeave = () => {
pointer.inside = false;
};
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
w = canvas.clientWidth;
h = canvas.clientHeight;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
dots = [];
for (let y = gap / 2; y < h; y += gap) {
for (let x = gap / 2; x < w; x += gap) dots.push({ x, y, ox: 0, oy: 0, lit: 0 });
}
draw();
};
const ro = new ResizeObserver(resize);
const io = new IntersectionObserver(([e]) => {
visible = e.isIntersecting;
if (!visible) {
cancelAnimationFrame(raf);
raf = 0;
}
});
ro.observe(canvas);
io.observe(canvas);
wrap.addEventListener("pointermove", onMove);
wrap.addEventListener("pointerleave", onLeave);
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
io.disconnect();
wrap.removeEventListener("pointermove", onMove);
wrap.removeEventListener("pointerleave", onLeave);
};
}, [color, dot, gap, radius]);
return (
<div ref={wrapRef} className={`relative overflow-hidden ${className}`} style={{ background }}>
<canvas ref={canvasRef} aria-hidden className="absolute inset-0 h-full w-full" />
<div className="relative h-full">{children}</div>
</div>
);
}
1. Paste the Code tab into
components/ui/magnetic-grid.tsx. The styles are Tailwind CSS v4 classes.2. Use it. This is the preview above:
app/magnetic-grid-demo.tsx
import { MagneticGrid } from "@/components/ui/magnetic-grid"; export default function MagneticGridExample() { return ( <MagneticGrid className="h-full"> <p className="absolute bottom-5 left-6 text-2xl font-medium text-white/90">Move your pointer</p> </MagneticGrid> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| children | ReactNode | — | Content shown over the grid. |
| color | string | "#a78bfa" | Colour dots light up to. |
| dot | string | "rgba(255,255,255,0.18)" | Colour of dots at rest. |
| background | string | "#07060f" | Base colour. |
| gap | number | 22 | Space between dots in pixels. |
| radius | number | 120 | Reach of the pointer in pixels. |
| className | string | "" | Give it a height here. |