Magnetic Dots
BackgroundsA dot grid backdrop driven by a real spring, not a lerp. Dots carry velocity, so they get flung past their rest position and ring back — and the field reads how fast you move, not just where you are: a swipe throws dots further than the same path taken slowly. Click for an expanding shockwave. The canvas is pointer-transparent and listeners bind to the container, so it keeps reacting under headlines and buttons. Fixed 1/120s sub-stepping keeps the spring stable and frame-rate independent; the dirty rect bounds the cost to the influence radius.
Move across the text.
The dots keep reacting under solid content. Swipe fast and they get thrown further than the same path taken slowly. Click for a shockwave.
Critical damping is 2·√stiffness — about 19 here. Below it the dots overshoot and ring; above it they crawl home. Drop drag to 0 and the field stops caring how fast you move, which is how a lerped grid always behaves.
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* MagneticDots — a dot grid driven by a real spring, sitting behind your
* content rather than in front of it.
*
* Two things separate this from a cursor-reactive grid that lerps toward a
* target position:
*
* **1 · It has momentum.** Each dot carries a velocity. The cursor applies a
* force, a spring pulls the dot home, damping bleeds the energy off — so dots
* get flung, sail *past* their rest position, and settle back with a wobble
* you tune via `stiffness` / `damping`. A lerp can't overshoot; that overshoot
* is the whole personality of the component.
*
* Because there is velocity, the field also reacts to how fast you move, not
* just where you are: `drag` couples pointer velocity into the force, so a
* fast swipe throws dots further than a slow crawl over the same pixels.
*
* **2 · It is pointer-transparent.** The canvas is `pointer-events: none` and
* the listeners live on the wrapper (or the window). A background whose
* listeners are on the canvas stops responding the moment you put a headline
* on top of it — which is exactly what a background is for.
*
* ## Integration, and why it's sub-stepped
*
* Semi-implicit Euler on a stiff spring is only conditionally stable: one long
* frame and `dt` spikes, the dot overshoots harder than it started, and the
* system diverges — dots fly off and never come back. Clamping `dt` hides that
* but makes the settle speed depend on frame rate.
*
* So the loop accumulates elapsed time and steps at a **fixed** 1/120 s, up to
* `MAX_SUBSTEPS` times per frame. Stable at any stiffness we expose, and
* identical motion at 60 Hz, 120 Hz, or a stuttering 30.
*
* Displacement is clamped to `maxDisplacement`. That is a physics decision
* (a hard swipe shouldn't scatter the grid) *and* a rendering one — it's what
* keeps the dirty rect bounded, below.
*
* ## Where the frame budget goes
*
* At 5,000 dots the cost is **draw calls**, not math. Two things bound it:
*
* - **Dirty-rect rendering.** Canvas is persistent, so only the changed region
* is cleared and redrawn: the union of the cursor's influence rect, the
* *wake* (dots still in motion), and any live shockwave annuli. Everything
* outside is already correct on screen. Per-frame cost tracks the radius,
* not the grid size.
* - **Batched paths.** Dots in the dirty region are bucketed by quantised
* alpha and each bucket is one path with one `fill()` — at most 16 fills a
* frame regardless of dot count.
*
* A uniform grid is its own spatial index: the affected window is
* `floor((rect − origin) / spacing)` per axis. O(1), nothing to build.
*
* The wake is tracked in **base** coordinates. A dot drawn at the edge of the
* wake has a grid position up to `maxDisplacement` away, so a bbox of *drawn*
* positions maps back to an index window that misses the dot it was drawn for
* — which then never repaints and smears.
*
* The settled test reads **position and velocity**. Offset alone is wrong
* here: a dot crossing its rest position at full speed has a near-zero offset,
* would drop out of the wake mid-flight, and would leave a trail behind it.
*
* ## The rAF loop genuinely stops
*
* Cancelled, not idling. Nothing in motion, offscreen via
* `IntersectionObserver`, or a backgrounded tab all stop it; pointer input
* restarts it. An always-on rAF in a background component is a battery bug
* someone else inherits.
*
* ## Other details
*
* - **DPR** — backing store scaled to `devicePixelRatio`, capped at 2. Some
* Android devices report 3+, which is 2.25× the fill rate for no visible
* gain.
* - **Pointer coords are converted in the frame, not the event.** Pointer
* events outpace frames, and `getBoundingClientRect()` per event is a forced
* layout read. The rect is cached and refreshed on resize and scroll.
* - **`ResizeObserver`, not `window.resize`** — the container can resize on
* its own.
* - Reduced motion paints the static grid once and never starts the loop.
* - Monochrome by default: ink resolves from the inherited `currentColor`,
* re-read when the theme flips, since a canvas can't inherit it live.
*/
/** Alpha levels used for path batching. More levels = smoother, more fills. */
const ALPHA_BUCKETS = 16;
const TAU = Math.PI * 2;
/** Beyond 2 the extra pixels cost fill rate and buy nothing visible. */
const MAX_DPR = 2;
/** Fixed physics step. Small enough to stay stable at high stiffness. */
const SUBSTEP = 1 / 120;
/** Cap the catch-up after a long frame, or a stall becomes a burst of work. */
const MAX_SUBSTEPS = 4;
/** Below both of these a dot counts as settled and drops out of the wake. */
const SETTLED_POS = 0.01;
const SETTLED_VEL = 0.05;
/** Pointer velocity decays to zero on this time constant when you stop moving. */
const VELOCITY_TAU = 0.12;
/** Raised cosine: 1 at the cursor, 0 at the radius, flat at both ends. */
const defaultFalloff = (t: number) => (1 + Math.cos(t * Math.PI)) / 2;
export interface ShockwaveOptions {
/** Expansion speed of the ring, px per second. */
speed?: number;
/** Half-thickness of the ring, in px. Dots within it get the impulse. */
width?: number;
/** Peak force at the centre of the ring, in px/s². */
force?: number;
/** Seconds before the ring is retired. */
lifetime?: number;
}
const SHOCKWAVE_DEFAULTS: Required<ShockwaveOptions> = {
speed: 620,
width: 70,
force: 2600,
lifetime: 0.9,
};
interface Rect {
x0: number;
y0: number;
x1: number;
y1: number;
}
function union(a: Rect | null, b: Rect | null): Rect | null {
if (!a) return b;
if (!b) return a;
return {
x0: Math.min(a.x0, b.x0),
y0: Math.min(a.y0, b.y0),
x1: Math.max(a.x1, b.x1),
y1: Math.max(a.y1, b.y1),
};
}
interface Wave {
x: number;
y: number;
/** Seconds since the wave was spawned. */
age: number;
}
interface LiveConfig {
dotSize: number;
radius: number;
strength: number;
stiffness: number;
damping: number;
drag: number;
maxDisplacement: number;
attract: boolean;
falloff: (t: number) => number;
opacity: number;
wave: Required<ShockwaveOptions> | null;
}
export interface MagneticDotsProps extends Omit<
React.ComponentProps<"div">,
"color"
> {
/** Grid pitch in CSS pixels. Lower = denser; this is what drives dot count. */
spacing?: number;
/** Base dot radius in CSS pixels. */
dotSize?: number;
/** Radius of the cursor's influence, in CSS pixels. */
radius?: number;
/** Peak push force at the cursor, in px/s². */
strength?: number;
/**
* Spring constant pulling each dot home. Higher = snappier return and a
* faster wobble. Paired with `damping`, this is the entire feel of the
* component.
*/
stiffness?: number;
/**
* Velocity damping. Critical damping is `2 · √stiffness` — the default sits
* deliberately below it, so dots overshoot and settle back visibly. Go above
* it and the motion turns to syrup.
*/
damping?: number;
/**
* How much pointer *velocity* drags dots along with it. 0 disables, and the
* field then reacts only to cursor position.
*/
drag?: number;
/**
* Hard cap on how far a dot can leave its grid position, in px. Bounds the
* dirty rect as well as the chaos.
*/
maxDisplacement?: number;
/** Pull dots toward the cursor instead of pushing them away. */
attract?: boolean;
/** Expanding ring of force on pointer-down. `true` uses the defaults. */
shockwave?: boolean | ShockwaveOptions;
/**
* Where pointer listeners are bound. `"container"` (default) reacts within
* this element's box; `"window"` keeps a full-page background live no matter
* what the cursor is over.
*/
pointerTarget?: "container" | "window";
/** Normalised distance 0…1 → intensity 0…1. Defaults to a raised cosine. */
falloff?: (t: number) => number;
/** Resting dot alpha. */
opacity?: number;
/** Any canvas-valid color. Defaults to the inherited `currentColor`. */
color?: string;
/** Fired on layout changes only, never per frame — safe to `setState` in. */
onMeasure?: (info: {
dots: number;
cols: number;
rows: number;
dpr: number;
}) => void;
}
export function MagneticDots({
spacing = 22,
dotSize = 1.6,
radius = 170,
strength = 2200,
stiffness = 90,
damping = 9,
drag = 0.9,
maxDisplacement = 46,
attract = false,
shockwave = true,
pointerTarget = "container",
falloff = defaultFalloff,
opacity = 0.35,
color,
onMeasure,
className,
children,
...props
}: MagneticDotsProps) {
const hostRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const waveOpts = React.useMemo<Required<ShockwaveOptions> | null>(() => {
if (!shockwave) return null;
return shockwave === true
? SHOCKWAVE_DEFAULTS
: { ...SHOCKWAVE_DEFAULTS, ...shockwave };
}, [shockwave]);
// Everything the frame loop reads lives behind a ref, synced after render
// (never during it), so changing a prop retunes the running loop without
// tearing it down.
const cfg = React.useRef<LiveConfig>({
dotSize,
radius,
strength,
stiffness,
damping,
drag,
maxDisplacement,
attract,
falloff,
opacity,
wave: waveOpts,
});
const onMeasureRef = React.useRef(onMeasure);
React.useEffect(() => {
cfg.current = {
dotSize,
radius,
strength,
stiffness,
damping,
drag,
maxDisplacement,
attract,
falloff,
opacity,
wave: waveOpts,
};
onMeasureRef.current = onMeasure;
});
React.useEffect(() => {
const host = hostRef.current;
const canvas = canvasRef.current;
if (!host || !canvas) return;
const ctx = canvas.getContext("2d", { alpha: true });
if (!ctx) return;
const reduce =
typeof matchMedia === "function" &&
matchMedia("(prefers-reduced-motion: reduce)").matches;
// --- grid -------------------------------------------------------------
let cssW = 0;
let cssH = 0;
let cols = 0;
let rows = 0;
let originX = 0;
let originY = 0;
/** Per dot: [offsetX, offsetY, velocityX, velocityY]. Rest is all zero. */
let state = new Float32Array(0);
let ink = "#000";
/** Cached so pointer events don't force a layout read each time. */
let boxLeft = 0;
let boxTop = 0;
// --- pointer ("no pointer" is the Infinity sentinel) -------------------
let px = Infinity;
let py = Infinity;
/** Client coords staged by the event, converted to local in the frame. */
let clientX = Infinity;
let clientY = Infinity;
let lastClientX = Infinity;
let lastClientY = Infinity;
let pvx = 0;
let pvy = 0;
const waves: Wave[] = [];
// --- loop -------------------------------------------------------------
let raf = 0;
let running = false;
let onScreen = true;
let last = 0;
let accumulator = 0;
/** Bbox (in base coordinates) of dots still in motion. */
let wake: Rect | null = null;
const buckets: number[][] = Array.from({ length: ALPHA_BUCKETS }, () => []);
const resolveInk = () => {
ink = color ?? getComputedStyle(canvas).color ?? "#000";
};
const measureBox = () => {
const r = canvas.getBoundingClientRect();
boxLeft = r.left;
boxTop = r.top;
};
/** How far a dot's drawn pixels can stray from its grid position. */
const reach = (c: LiveConfig) => c.maxDisplacement + c.dotSize + 2;
// --- physics ----------------------------------------------------------
/**
* Advance one dot by one fixed sub-step. Mutates `state` in place; a frame
* may run several sub-steps but only ever draws once.
*/
const integrate = (i: number, bx: number, by: number, c: LiveConfig) => {
const k = i * 4;
let fx = -c.stiffness * state[k];
let fy = -c.stiffness * state[k + 1];
if (Number.isFinite(px)) {
const dx = bx - px;
const dy = by - py;
const d2 = dx * dx + dy * dy;
if (d2 < c.radius * c.radius) {
const d = Math.sqrt(d2) || 1e-4;
const t = c.falloff(d / c.radius);
const intensity = t < 0 ? 0 : t > 1 ? 1 : t;
const push = c.strength * intensity * (c.attract ? -1 : 1);
fx += (dx / d) * push;
fy += (dy / d) * push;
// Pointer velocity smears the field along the direction of travel —
// this is what makes a fast swipe feel different from a slow one.
fx += pvx * c.drag * intensity;
fy += pvy * c.drag * intensity;
}
}
const opts = c.wave;
if (opts) {
for (let w = 0; w < waves.length; w++) {
const wave = waves[w];
const dx = bx - wave.x;
const dy = by - wave.y;
const d = Math.sqrt(dx * dx + dy * dy) || 1e-4;
const ring = Math.abs(d - opts.speed * wave.age);
if (ring < opts.width) {
// Cosine across the ring's thickness, linear fade over its life.
const shape = (1 + Math.cos((ring / opts.width) * Math.PI)) / 2;
const decay = 1 - wave.age / opts.lifetime;
const impulse = opts.force * shape * decay;
fx += (dx / d) * impulse;
fy += (dy / d) * impulse;
}
}
}
// Semi-implicit Euler: velocity first, then position from the *new*
// velocity. Cheaper than RK4 and stable at this fixed step.
let vx = (state[k + 2] += (fx - c.damping * state[k + 2]) * SUBSTEP);
let vy = (state[k + 3] += (fy - c.damping * state[k + 3]) * SUBSTEP);
let ox = state[k] + vx * SUBSTEP;
let oy = state[k + 1] + vy * SUBSTEP;
// Clamp displacement, and strip the outward velocity component with it.
// Otherwise a pinned dot keeps accumulating speed against the cap and
// snaps back violently the instant the cursor leaves.
const mag = Math.sqrt(ox * ox + oy * oy);
if (mag > c.maxDisplacement) {
const s = c.maxDisplacement / mag;
ox *= s;
oy *= s;
const nx = ox / c.maxDisplacement;
const ny = oy / c.maxDisplacement;
const radial = vx * nx + vy * ny;
if (radial > 0) {
vx -= nx * radial;
vy -= ny * radial;
state[k + 2] = vx;
state[k + 3] = vy;
}
}
state[k] = ox;
state[k + 1] = oy;
};
// --- drawing ----------------------------------------------------------
/**
* Step and draw every dot in an index window, bucketed by alpha so each
* bucket costs one path and one fill. Returns the bbox of dots still in
* motion, in base coordinates.
*/
const drawWindow = (
c0: number,
c1: number,
r0: number,
r1: number,
steps: number,
) => {
const c = cfg.current;
const pad = reach(c);
const visible = c.dotSize > 0.05 && c.opacity > 1 / (ALPHA_BUCKETS * 4);
// Alpha is uniform here (the cursor moves dots, not their opacity), so
// the bucket index is computed once and every dot lands in it.
const bucket = Math.min(
ALPHA_BUCKETS - 1,
Math.max(0, Math.floor(c.opacity * ALPHA_BUCKETS)),
);
for (const b of buckets) b.length = 0;
let liveX0 = Infinity;
let liveY0 = Infinity;
let liveX1 = -Infinity;
let liveY1 = -Infinity;
for (let row = r0; row <= r1; row++) {
for (let col = c0; col <= c1; col++) {
const i = row * cols + col;
const bx = originX + col * spacing;
const by = originY + row * spacing;
for (let s = 0; s < steps; s++) integrate(i, bx, by, c);
const k = i * 4;
const ox = state[k];
const oy = state[k + 1];
if (visible) buckets[bucket].push(bx + ox, by + oy, c.dotSize);
if (
Math.abs(ox) > SETTLED_POS ||
Math.abs(oy) > SETTLED_POS ||
Math.abs(state[k + 2]) > SETTLED_VEL ||
Math.abs(state[k + 3]) > SETTLED_VEL
) {
if (bx - pad < liveX0) liveX0 = bx - pad;
if (by - pad < liveY0) liveY0 = by - pad;
if (bx + pad > liveX1) liveX1 = bx + pad;
if (by + pad > liveY1) liveY1 = by + pad;
}
}
}
ctx.fillStyle = ink;
for (let b = 0; b < ALPHA_BUCKETS; b++) {
const arr = buckets[b];
if (arr.length === 0) continue;
ctx.globalAlpha = (b + 0.5) / ALPHA_BUCKETS;
ctx.beginPath();
for (let i = 0; i < arr.length; i += 3) {
// moveTo before every arc, or each dot is joined to the previous by
// a connecting line segment.
ctx.moveTo(arr[i] + arr[i + 2], arr[i + 1]);
ctx.arc(arr[i], arr[i + 1], arr[i + 2], 0, TAU);
}
ctx.fill();
}
ctx.globalAlpha = 1;
return liveX1 === -Infinity
? null
: { x0: liveX0, y0: liveY0, x1: liveX1, y1: liveY1 };
};
const indexWindow = (rect: Rect) => ({
c0: Math.max(0, Math.floor((rect.x0 - originX) / spacing)),
c1: Math.min(cols - 1, Math.ceil((rect.x1 - originX) / spacing)),
r0: Math.max(0, Math.floor((rect.y0 - originY) / spacing)),
r1: Math.min(rows - 1, Math.ceil((rect.y1 - originY) / spacing)),
});
/** Full repaint with zero sub-steps — layout and theme changes only. */
const paintAll = () => {
if (cols === 0 || rows === 0) return;
ctx.clearRect(0, 0, cssW, cssH);
wake = drawWindow(0, cols - 1, 0, rows - 1, 0);
};
const influenceRect = (): Rect | null => {
if (!Number.isFinite(px)) return null;
const c = cfg.current;
const pad = reach(c);
return {
x0: px - c.radius - pad,
y0: py - c.radius - pad,
x1: px + c.radius + pad,
y1: py + c.radius + pad,
};
};
const waveRect = (): Rect | null => {
const opts = cfg.current.wave;
if (!opts || waves.length === 0) return null;
const pad = reach(cfg.current) + opts.width;
let r: Rect | null = null;
for (const w of waves) {
const rad = opts.speed * w.age + pad;
r = union(r, {
x0: w.x - rad,
y0: w.y - rad,
x1: w.x + rad,
y1: w.y + rad,
});
}
return r;
};
const frame = (now: number) => {
const dt = last === 0 ? SUBSTEP : Math.min((now - last) / 1000, 0.1);
last = now;
// Fixed-step accumulator: the physics never sees a variable dt, so it
// can't blow up on a long frame and looks identical at any refresh rate.
accumulator += dt;
let steps = Math.floor(accumulator / SUBSTEP);
if (steps > MAX_SUBSTEPS) steps = MAX_SUBSTEPS;
accumulator -= steps * SUBSTEP;
const simulated = steps * SUBSTEP;
// Local pointer coords resolve here, not in the handler: pointer events
// outpace frames and the rect read would be a layout cost per event.
if (Number.isFinite(clientX)) {
px = clientX - boxLeft;
py = clientY - boxTop;
} else {
px = Infinity;
py = Infinity;
}
// Pointer velocity is accumulated by the events and bled off here, so it
// fades when the cursor stops rather than sticking at its last value.
const decay = Math.exp(-dt / VELOCITY_TAU);
pvx *= decay;
pvy *= decay;
// Measured before ageing: a wave's dirty rect must cover where it was
// *and* where it now is, or it clears a ring it already drew.
const waveBox = waveRect();
const opts = cfg.current.wave;
if (opts) {
for (let i = waves.length - 1; i >= 0; i--) {
waves[i].age += simulated;
if (waves[i].age >= opts.lifetime) waves.splice(i, 1);
}
} else {
waves.length = 0;
}
const dirty = union(union(influenceRect(), wake), waveBox);
if (!dirty) {
// Nothing in motion and nothing to move it — actually stop, don't idle.
running = false;
last = 0;
accumulator = 0;
return;
}
const x0 = Math.max(0, dirty.x0);
const y0 = Math.max(0, dirty.y0);
const x1 = Math.min(cssW, dirty.x1);
const y1 = Math.min(cssH, dirty.y1);
if (x1 > x0 && y1 > y0) ctx.clearRect(x0, y0, x1 - x0, y1 - y0);
const w = indexWindow(dirty);
wake =
w.c1 < w.c0 || w.r1 < w.r0
? null
: drawWindow(w.c0, w.c1, w.r0, w.r1, steps);
raf = requestAnimationFrame(frame);
};
const start = () => {
if (running || reduce || !onScreen || document.hidden) return;
running = true;
last = 0;
accumulator = 0;
raf = requestAnimationFrame(frame);
};
const stop = () => {
if (!running) return;
running = false;
cancelAnimationFrame(raf);
last = 0;
accumulator = 0;
};
// --- layout -----------------------------------------------------------
const layout = () => {
const rect = canvas.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
cssW = rect.width;
cssH = rect.height;
boxLeft = rect.left;
boxTop = rect.top;
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
canvas.width = Math.round(cssW * dpr);
canvas.height = Math.round(cssH * dpr);
// Draw in CSS pixels; the backing store carries the extra resolution.
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
cols = Math.max(1, Math.floor(cssW / spacing) + 1);
rows = Math.max(1, Math.floor(cssH / spacing) + 1);
// Centre the grid so the margins match on opposite edges.
originX = (cssW - (cols - 1) * spacing) / 2;
originY = (cssH - (rows - 1) * spacing) / 2;
state = new Float32Array(cols * rows * 4);
waves.length = 0;
resolveInk();
paintAll();
onMeasureRef.current?.({ dots: cols * rows, cols, rows, dpr });
};
const ro = new ResizeObserver(layout);
ro.observe(canvas);
// Canvas can't inherit `currentColor` live, so re-resolve when the theme
// attribute changes and repaint if the ink actually moved.
const mo = new MutationObserver(() => {
const before = ink;
resolveInk();
if (before !== ink) paintAll();
});
mo.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class", "style", "data-theme"],
});
const io = new IntersectionObserver((entries) => {
onScreen = entries[entries.length - 1]?.isIntersecting ?? true;
if (onScreen) start();
else stop();
});
io.observe(canvas);
const onVisibility = () => {
if (document.hidden) stop();
else start();
};
document.addEventListener("visibilitychange", onVisibility);
// Scrolling slides the canvas under a stationary cursor, so the cached
// rect has to follow. Capture catches scrolls in any ancestor too.
const onScroll = () => measureBox();
window.addEventListener("scroll", onScroll, {
passive: true,
capture: true,
});
// --- pointer ----------------------------------------------------------
const target: HTMLElement | Window =
pointerTarget === "window" ? window : host;
const track = (e: PointerEvent) => {
if (Number.isFinite(lastClientX)) {
// Raw per-event delta. The frame decays it, so a burst of events
// between two frames sums into a stronger drag — which is correct.
pvx += e.clientX - lastClientX;
pvy += e.clientY - lastClientY;
}
lastClientX = e.clientX;
lastClientY = e.clientY;
clientX = e.clientX;
clientY = e.clientY;
start();
};
const release = () => {
clientX = Infinity;
clientY = Infinity;
lastClientX = Infinity;
lastClientY = Infinity;
pvx = 0;
pvy = 0;
// Still start: the dots have to spring back from wherever they were left.
start();
};
const onDown = (e: PointerEvent) => {
track(e);
if (!cfg.current.wave) return;
const x = e.clientX - boxLeft;
const y = e.clientY - boxTop;
if (x < 0 || y < 0 || x > cssW || y > cssH) return;
waves.push({ x, y, age: 0 });
start();
};
const listenerOpts = { passive: true } as const;
target.addEventListener(
"pointermove",
track as EventListener,
listenerOpts,
);
target.addEventListener(
"pointerdown",
onDown as EventListener,
listenerOpts,
);
target.addEventListener("pointerleave", release, listenerOpts);
target.addEventListener("pointercancel", release, listenerOpts);
return () => {
stop();
ro.disconnect();
mo.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
window.removeEventListener("scroll", onScroll, { capture: true });
target.removeEventListener("pointermove", track as EventListener);
target.removeEventListener("pointerdown", onDown as EventListener);
target.removeEventListener("pointerleave", release);
target.removeEventListener("pointercancel", release);
};
// Only what changes the grid's identity or the listener target rebuilds
// the loop; everything else is read live from `cfg`.
}, [spacing, color, pointerTarget]);
return (
<div
ref={hostRef}
data-slot="magnetic-dots"
className={cn(
"relative h-full w-full text-zinc-900 dark:text-zinc-100",
className,
)}
{...props}
>
<canvas
ref={canvasRef}
aria-hidden
data-slot="magnetic-dots-canvas"
// pointer-events-none is load-bearing: it's what lets content sit on
// top without stealing the events that drive the field.
className="pointer-events-none absolute inset-0 block h-full w-full"
/>
{children}
</div>
);
}Installation
npx shadcn@latest add https://ui.saumyarex.xyz/r/magnetic-dots.json1. Install dependencies
npm install clsx tailwind-merge2. Copy the source into your project
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* MagneticDots — a dot grid driven by a real spring, sitting behind your
* content rather than in front of it.
*
* Two things separate this from a cursor-reactive grid that lerps toward a
* target position:
*
* **1 · It has momentum.** Each dot carries a velocity. The cursor applies a
* force, a spring pulls the dot home, damping bleeds the energy off — so dots
* get flung, sail *past* their rest position, and settle back with a wobble
* you tune via `stiffness` / `damping`. A lerp can't overshoot; that overshoot
* is the whole personality of the component.
*
* Because there is velocity, the field also reacts to how fast you move, not
* just where you are: `drag` couples pointer velocity into the force, so a
* fast swipe throws dots further than a slow crawl over the same pixels.
*
* **2 · It is pointer-transparent.** The canvas is `pointer-events: none` and
* the listeners live on the wrapper (or the window). A background whose
* listeners are on the canvas stops responding the moment you put a headline
* on top of it — which is exactly what a background is for.
*
* ## Integration, and why it's sub-stepped
*
* Semi-implicit Euler on a stiff spring is only conditionally stable: one long
* frame and `dt` spikes, the dot overshoots harder than it started, and the
* system diverges — dots fly off and never come back. Clamping `dt` hides that
* but makes the settle speed depend on frame rate.
*
* So the loop accumulates elapsed time and steps at a **fixed** 1/120 s, up to
* `MAX_SUBSTEPS` times per frame. Stable at any stiffness we expose, and
* identical motion at 60 Hz, 120 Hz, or a stuttering 30.
*
* Displacement is clamped to `maxDisplacement`. That is a physics decision
* (a hard swipe shouldn't scatter the grid) *and* a rendering one — it's what
* keeps the dirty rect bounded, below.
*
* ## Where the frame budget goes
*
* At 5,000 dots the cost is **draw calls**, not math. Two things bound it:
*
* - **Dirty-rect rendering.** Canvas is persistent, so only the changed region
* is cleared and redrawn: the union of the cursor's influence rect, the
* *wake* (dots still in motion), and any live shockwave annuli. Everything
* outside is already correct on screen. Per-frame cost tracks the radius,
* not the grid size.
* - **Batched paths.** Dots in the dirty region are bucketed by quantised
* alpha and each bucket is one path with one `fill()` — at most 16 fills a
* frame regardless of dot count.
*
* A uniform grid is its own spatial index: the affected window is
* `floor((rect − origin) / spacing)` per axis. O(1), nothing to build.
*
* The wake is tracked in **base** coordinates. A dot drawn at the edge of the
* wake has a grid position up to `maxDisplacement` away, so a bbox of *drawn*
* positions maps back to an index window that misses the dot it was drawn for
* — which then never repaints and smears.
*
* The settled test reads **position and velocity**. Offset alone is wrong
* here: a dot crossing its rest position at full speed has a near-zero offset,
* would drop out of the wake mid-flight, and would leave a trail behind it.
*
* ## The rAF loop genuinely stops
*
* Cancelled, not idling. Nothing in motion, offscreen via
* `IntersectionObserver`, or a backgrounded tab all stop it; pointer input
* restarts it. An always-on rAF in a background component is a battery bug
* someone else inherits.
*
* ## Other details
*
* - **DPR** — backing store scaled to `devicePixelRatio`, capped at 2. Some
* Android devices report 3+, which is 2.25× the fill rate for no visible
* gain.
* - **Pointer coords are converted in the frame, not the event.** Pointer
* events outpace frames, and `getBoundingClientRect()` per event is a forced
* layout read. The rect is cached and refreshed on resize and scroll.
* - **`ResizeObserver`, not `window.resize`** — the container can resize on
* its own.
* - Reduced motion paints the static grid once and never starts the loop.
* - Monochrome by default: ink resolves from the inherited `currentColor`,
* re-read when the theme flips, since a canvas can't inherit it live.
*/
/** Alpha levels used for path batching. More levels = smoother, more fills. */
const ALPHA_BUCKETS = 16;
const TAU = Math.PI * 2;
/** Beyond 2 the extra pixels cost fill rate and buy nothing visible. */
const MAX_DPR = 2;
/** Fixed physics step. Small enough to stay stable at high stiffness. */
const SUBSTEP = 1 / 120;
/** Cap the catch-up after a long frame, or a stall becomes a burst of work. */
const MAX_SUBSTEPS = 4;
/** Below both of these a dot counts as settled and drops out of the wake. */
const SETTLED_POS = 0.01;
const SETTLED_VEL = 0.05;
/** Pointer velocity decays to zero on this time constant when you stop moving. */
const VELOCITY_TAU = 0.12;
/** Raised cosine: 1 at the cursor, 0 at the radius, flat at both ends. */
const defaultFalloff = (t: number) => (1 + Math.cos(t * Math.PI)) / 2;
export interface ShockwaveOptions {
/** Expansion speed of the ring, px per second. */
speed?: number;
/** Half-thickness of the ring, in px. Dots within it get the impulse. */
width?: number;
/** Peak force at the centre of the ring, in px/s². */
force?: number;
/** Seconds before the ring is retired. */
lifetime?: number;
}
const SHOCKWAVE_DEFAULTS: Required<ShockwaveOptions> = {
speed: 620,
width: 70,
force: 2600,
lifetime: 0.9,
};
interface Rect {
x0: number;
y0: number;
x1: number;
y1: number;
}
function union(a: Rect | null, b: Rect | null): Rect | null {
if (!a) return b;
if (!b) return a;
return {
x0: Math.min(a.x0, b.x0),
y0: Math.min(a.y0, b.y0),
x1: Math.max(a.x1, b.x1),
y1: Math.max(a.y1, b.y1),
};
}
interface Wave {
x: number;
y: number;
/** Seconds since the wave was spawned. */
age: number;
}
interface LiveConfig {
dotSize: number;
radius: number;
strength: number;
stiffness: number;
damping: number;
drag: number;
maxDisplacement: number;
attract: boolean;
falloff: (t: number) => number;
opacity: number;
wave: Required<ShockwaveOptions> | null;
}
export interface MagneticDotsProps extends Omit<
React.ComponentProps<"div">,
"color"
> {
/** Grid pitch in CSS pixels. Lower = denser; this is what drives dot count. */
spacing?: number;
/** Base dot radius in CSS pixels. */
dotSize?: number;
/** Radius of the cursor's influence, in CSS pixels. */
radius?: number;
/** Peak push force at the cursor, in px/s². */
strength?: number;
/**
* Spring constant pulling each dot home. Higher = snappier return and a
* faster wobble. Paired with `damping`, this is the entire feel of the
* component.
*/
stiffness?: number;
/**
* Velocity damping. Critical damping is `2 · √stiffness` — the default sits
* deliberately below it, so dots overshoot and settle back visibly. Go above
* it and the motion turns to syrup.
*/
damping?: number;
/**
* How much pointer *velocity* drags dots along with it. 0 disables, and the
* field then reacts only to cursor position.
*/
drag?: number;
/**
* Hard cap on how far a dot can leave its grid position, in px. Bounds the
* dirty rect as well as the chaos.
*/
maxDisplacement?: number;
/** Pull dots toward the cursor instead of pushing them away. */
attract?: boolean;
/** Expanding ring of force on pointer-down. `true` uses the defaults. */
shockwave?: boolean | ShockwaveOptions;
/**
* Where pointer listeners are bound. `"container"` (default) reacts within
* this element's box; `"window"` keeps a full-page background live no matter
* what the cursor is over.
*/
pointerTarget?: "container" | "window";
/** Normalised distance 0…1 → intensity 0…1. Defaults to a raised cosine. */
falloff?: (t: number) => number;
/** Resting dot alpha. */
opacity?: number;
/** Any canvas-valid color. Defaults to the inherited `currentColor`. */
color?: string;
/** Fired on layout changes only, never per frame — safe to `setState` in. */
onMeasure?: (info: {
dots: number;
cols: number;
rows: number;
dpr: number;
}) => void;
}
export function MagneticDots({
spacing = 22,
dotSize = 1.6,
radius = 170,
strength = 2200,
stiffness = 90,
damping = 9,
drag = 0.9,
maxDisplacement = 46,
attract = false,
shockwave = true,
pointerTarget = "container",
falloff = defaultFalloff,
opacity = 0.35,
color,
onMeasure,
className,
children,
...props
}: MagneticDotsProps) {
const hostRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const waveOpts = React.useMemo<Required<ShockwaveOptions> | null>(() => {
if (!shockwave) return null;
return shockwave === true
? SHOCKWAVE_DEFAULTS
: { ...SHOCKWAVE_DEFAULTS, ...shockwave };
}, [shockwave]);
// Everything the frame loop reads lives behind a ref, synced after render
// (never during it), so changing a prop retunes the running loop without
// tearing it down.
const cfg = React.useRef<LiveConfig>({
dotSize,
radius,
strength,
stiffness,
damping,
drag,
maxDisplacement,
attract,
falloff,
opacity,
wave: waveOpts,
});
const onMeasureRef = React.useRef(onMeasure);
React.useEffect(() => {
cfg.current = {
dotSize,
radius,
strength,
stiffness,
damping,
drag,
maxDisplacement,
attract,
falloff,
opacity,
wave: waveOpts,
};
onMeasureRef.current = onMeasure;
});
React.useEffect(() => {
const host = hostRef.current;
const canvas = canvasRef.current;
if (!host || !canvas) return;
const ctx = canvas.getContext("2d", { alpha: true });
if (!ctx) return;
const reduce =
typeof matchMedia === "function" &&
matchMedia("(prefers-reduced-motion: reduce)").matches;
// --- grid -------------------------------------------------------------
let cssW = 0;
let cssH = 0;
let cols = 0;
let rows = 0;
let originX = 0;
let originY = 0;
/** Per dot: [offsetX, offsetY, velocityX, velocityY]. Rest is all zero. */
let state = new Float32Array(0);
let ink = "#000";
/** Cached so pointer events don't force a layout read each time. */
let boxLeft = 0;
let boxTop = 0;
// --- pointer ("no pointer" is the Infinity sentinel) -------------------
let px = Infinity;
let py = Infinity;
/** Client coords staged by the event, converted to local in the frame. */
let clientX = Infinity;
let clientY = Infinity;
let lastClientX = Infinity;
let lastClientY = Infinity;
let pvx = 0;
let pvy = 0;
const waves: Wave[] = [];
// --- loop -------------------------------------------------------------
let raf = 0;
let running = false;
let onScreen = true;
let last = 0;
let accumulator = 0;
/** Bbox (in base coordinates) of dots still in motion. */
let wake: Rect | null = null;
const buckets: number[][] = Array.from({ length: ALPHA_BUCKETS }, () => []);
const resolveInk = () => {
ink = color ?? getComputedStyle(canvas).color ?? "#000";
};
const measureBox = () => {
const r = canvas.getBoundingClientRect();
boxLeft = r.left;
boxTop = r.top;
};
/** How far a dot's drawn pixels can stray from its grid position. */
const reach = (c: LiveConfig) => c.maxDisplacement + c.dotSize + 2;
// --- physics ----------------------------------------------------------
/**
* Advance one dot by one fixed sub-step. Mutates `state` in place; a frame
* may run several sub-steps but only ever draws once.
*/
const integrate = (i: number, bx: number, by: number, c: LiveConfig) => {
const k = i * 4;
let fx = -c.stiffness * state[k];
let fy = -c.stiffness * state[k + 1];
if (Number.isFinite(px)) {
const dx = bx - px;
const dy = by - py;
const d2 = dx * dx + dy * dy;
if (d2 < c.radius * c.radius) {
const d = Math.sqrt(d2) || 1e-4;
const t = c.falloff(d / c.radius);
const intensity = t < 0 ? 0 : t > 1 ? 1 : t;
const push = c.strength * intensity * (c.attract ? -1 : 1);
fx += (dx / d) * push;
fy += (dy / d) * push;
// Pointer velocity smears the field along the direction of travel —
// this is what makes a fast swipe feel different from a slow one.
fx += pvx * c.drag * intensity;
fy += pvy * c.drag * intensity;
}
}
const opts = c.wave;
if (opts) {
for (let w = 0; w < waves.length; w++) {
const wave = waves[w];
const dx = bx - wave.x;
const dy = by - wave.y;
const d = Math.sqrt(dx * dx + dy * dy) || 1e-4;
const ring = Math.abs(d - opts.speed * wave.age);
if (ring < opts.width) {
// Cosine across the ring's thickness, linear fade over its life.
const shape = (1 + Math.cos((ring / opts.width) * Math.PI)) / 2;
const decay = 1 - wave.age / opts.lifetime;
const impulse = opts.force * shape * decay;
fx += (dx / d) * impulse;
fy += (dy / d) * impulse;
}
}
}
// Semi-implicit Euler: velocity first, then position from the *new*
// velocity. Cheaper than RK4 and stable at this fixed step.
let vx = (state[k + 2] += (fx - c.damping * state[k + 2]) * SUBSTEP);
let vy = (state[k + 3] += (fy - c.damping * state[k + 3]) * SUBSTEP);
let ox = state[k] + vx * SUBSTEP;
let oy = state[k + 1] + vy * SUBSTEP;
// Clamp displacement, and strip the outward velocity component with it.
// Otherwise a pinned dot keeps accumulating speed against the cap and
// snaps back violently the instant the cursor leaves.
const mag = Math.sqrt(ox * ox + oy * oy);
if (mag > c.maxDisplacement) {
const s = c.maxDisplacement / mag;
ox *= s;
oy *= s;
const nx = ox / c.maxDisplacement;
const ny = oy / c.maxDisplacement;
const radial = vx * nx + vy * ny;
if (radial > 0) {
vx -= nx * radial;
vy -= ny * radial;
state[k + 2] = vx;
state[k + 3] = vy;
}
}
state[k] = ox;
state[k + 1] = oy;
};
// --- drawing ----------------------------------------------------------
/**
* Step and draw every dot in an index window, bucketed by alpha so each
* bucket costs one path and one fill. Returns the bbox of dots still in
* motion, in base coordinates.
*/
const drawWindow = (
c0: number,
c1: number,
r0: number,
r1: number,
steps: number,
) => {
const c = cfg.current;
const pad = reach(c);
const visible = c.dotSize > 0.05 && c.opacity > 1 / (ALPHA_BUCKETS * 4);
// Alpha is uniform here (the cursor moves dots, not their opacity), so
// the bucket index is computed once and every dot lands in it.
const bucket = Math.min(
ALPHA_BUCKETS - 1,
Math.max(0, Math.floor(c.opacity * ALPHA_BUCKETS)),
);
for (const b of buckets) b.length = 0;
let liveX0 = Infinity;
let liveY0 = Infinity;
let liveX1 = -Infinity;
let liveY1 = -Infinity;
for (let row = r0; row <= r1; row++) {
for (let col = c0; col <= c1; col++) {
const i = row * cols + col;
const bx = originX + col * spacing;
const by = originY + row * spacing;
for (let s = 0; s < steps; s++) integrate(i, bx, by, c);
const k = i * 4;
const ox = state[k];
const oy = state[k + 1];
if (visible) buckets[bucket].push(bx + ox, by + oy, c.dotSize);
if (
Math.abs(ox) > SETTLED_POS ||
Math.abs(oy) > SETTLED_POS ||
Math.abs(state[k + 2]) > SETTLED_VEL ||
Math.abs(state[k + 3]) > SETTLED_VEL
) {
if (bx - pad < liveX0) liveX0 = bx - pad;
if (by - pad < liveY0) liveY0 = by - pad;
if (bx + pad > liveX1) liveX1 = bx + pad;
if (by + pad > liveY1) liveY1 = by + pad;
}
}
}
ctx.fillStyle = ink;
for (let b = 0; b < ALPHA_BUCKETS; b++) {
const arr = buckets[b];
if (arr.length === 0) continue;
ctx.globalAlpha = (b + 0.5) / ALPHA_BUCKETS;
ctx.beginPath();
for (let i = 0; i < arr.length; i += 3) {
// moveTo before every arc, or each dot is joined to the previous by
// a connecting line segment.
ctx.moveTo(arr[i] + arr[i + 2], arr[i + 1]);
ctx.arc(arr[i], arr[i + 1], arr[i + 2], 0, TAU);
}
ctx.fill();
}
ctx.globalAlpha = 1;
return liveX1 === -Infinity
? null
: { x0: liveX0, y0: liveY0, x1: liveX1, y1: liveY1 };
};
const indexWindow = (rect: Rect) => ({
c0: Math.max(0, Math.floor((rect.x0 - originX) / spacing)),
c1: Math.min(cols - 1, Math.ceil((rect.x1 - originX) / spacing)),
r0: Math.max(0, Math.floor((rect.y0 - originY) / spacing)),
r1: Math.min(rows - 1, Math.ceil((rect.y1 - originY) / spacing)),
});
/** Full repaint with zero sub-steps — layout and theme changes only. */
const paintAll = () => {
if (cols === 0 || rows === 0) return;
ctx.clearRect(0, 0, cssW, cssH);
wake = drawWindow(0, cols - 1, 0, rows - 1, 0);
};
const influenceRect = (): Rect | null => {
if (!Number.isFinite(px)) return null;
const c = cfg.current;
const pad = reach(c);
return {
x0: px - c.radius - pad,
y0: py - c.radius - pad,
x1: px + c.radius + pad,
y1: py + c.radius + pad,
};
};
const waveRect = (): Rect | null => {
const opts = cfg.current.wave;
if (!opts || waves.length === 0) return null;
const pad = reach(cfg.current) + opts.width;
let r: Rect | null = null;
for (const w of waves) {
const rad = opts.speed * w.age + pad;
r = union(r, {
x0: w.x - rad,
y0: w.y - rad,
x1: w.x + rad,
y1: w.y + rad,
});
}
return r;
};
const frame = (now: number) => {
const dt = last === 0 ? SUBSTEP : Math.min((now - last) / 1000, 0.1);
last = now;
// Fixed-step accumulator: the physics never sees a variable dt, so it
// can't blow up on a long frame and looks identical at any refresh rate.
accumulator += dt;
let steps = Math.floor(accumulator / SUBSTEP);
if (steps > MAX_SUBSTEPS) steps = MAX_SUBSTEPS;
accumulator -= steps * SUBSTEP;
const simulated = steps * SUBSTEP;
// Local pointer coords resolve here, not in the handler: pointer events
// outpace frames and the rect read would be a layout cost per event.
if (Number.isFinite(clientX)) {
px = clientX - boxLeft;
py = clientY - boxTop;
} else {
px = Infinity;
py = Infinity;
}
// Pointer velocity is accumulated by the events and bled off here, so it
// fades when the cursor stops rather than sticking at its last value.
const decay = Math.exp(-dt / VELOCITY_TAU);
pvx *= decay;
pvy *= decay;
// Measured before ageing: a wave's dirty rect must cover where it was
// *and* where it now is, or it clears a ring it already drew.
const waveBox = waveRect();
const opts = cfg.current.wave;
if (opts) {
for (let i = waves.length - 1; i >= 0; i--) {
waves[i].age += simulated;
if (waves[i].age >= opts.lifetime) waves.splice(i, 1);
}
} else {
waves.length = 0;
}
const dirty = union(union(influenceRect(), wake), waveBox);
if (!dirty) {
// Nothing in motion and nothing to move it — actually stop, don't idle.
running = false;
last = 0;
accumulator = 0;
return;
}
const x0 = Math.max(0, dirty.x0);
const y0 = Math.max(0, dirty.y0);
const x1 = Math.min(cssW, dirty.x1);
const y1 = Math.min(cssH, dirty.y1);
if (x1 > x0 && y1 > y0) ctx.clearRect(x0, y0, x1 - x0, y1 - y0);
const w = indexWindow(dirty);
wake =
w.c1 < w.c0 || w.r1 < w.r0
? null
: drawWindow(w.c0, w.c1, w.r0, w.r1, steps);
raf = requestAnimationFrame(frame);
};
const start = () => {
if (running || reduce || !onScreen || document.hidden) return;
running = true;
last = 0;
accumulator = 0;
raf = requestAnimationFrame(frame);
};
const stop = () => {
if (!running) return;
running = false;
cancelAnimationFrame(raf);
last = 0;
accumulator = 0;
};
// --- layout -----------------------------------------------------------
const layout = () => {
const rect = canvas.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
cssW = rect.width;
cssH = rect.height;
boxLeft = rect.left;
boxTop = rect.top;
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
canvas.width = Math.round(cssW * dpr);
canvas.height = Math.round(cssH * dpr);
// Draw in CSS pixels; the backing store carries the extra resolution.
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
cols = Math.max(1, Math.floor(cssW / spacing) + 1);
rows = Math.max(1, Math.floor(cssH / spacing) + 1);
// Centre the grid so the margins match on opposite edges.
originX = (cssW - (cols - 1) * spacing) / 2;
originY = (cssH - (rows - 1) * spacing) / 2;
state = new Float32Array(cols * rows * 4);
waves.length = 0;
resolveInk();
paintAll();
onMeasureRef.current?.({ dots: cols * rows, cols, rows, dpr });
};
const ro = new ResizeObserver(layout);
ro.observe(canvas);
// Canvas can't inherit `currentColor` live, so re-resolve when the theme
// attribute changes and repaint if the ink actually moved.
const mo = new MutationObserver(() => {
const before = ink;
resolveInk();
if (before !== ink) paintAll();
});
mo.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class", "style", "data-theme"],
});
const io = new IntersectionObserver((entries) => {
onScreen = entries[entries.length - 1]?.isIntersecting ?? true;
if (onScreen) start();
else stop();
});
io.observe(canvas);
const onVisibility = () => {
if (document.hidden) stop();
else start();
};
document.addEventListener("visibilitychange", onVisibility);
// Scrolling slides the canvas under a stationary cursor, so the cached
// rect has to follow. Capture catches scrolls in any ancestor too.
const onScroll = () => measureBox();
window.addEventListener("scroll", onScroll, {
passive: true,
capture: true,
});
// --- pointer ----------------------------------------------------------
const target: HTMLElement | Window =
pointerTarget === "window" ? window : host;
const track = (e: PointerEvent) => {
if (Number.isFinite(lastClientX)) {
// Raw per-event delta. The frame decays it, so a burst of events
// between two frames sums into a stronger drag — which is correct.
pvx += e.clientX - lastClientX;
pvy += e.clientY - lastClientY;
}
lastClientX = e.clientX;
lastClientY = e.clientY;
clientX = e.clientX;
clientY = e.clientY;
start();
};
const release = () => {
clientX = Infinity;
clientY = Infinity;
lastClientX = Infinity;
lastClientY = Infinity;
pvx = 0;
pvy = 0;
// Still start: the dots have to spring back from wherever they were left.
start();
};
const onDown = (e: PointerEvent) => {
track(e);
if (!cfg.current.wave) return;
const x = e.clientX - boxLeft;
const y = e.clientY - boxTop;
if (x < 0 || y < 0 || x > cssW || y > cssH) return;
waves.push({ x, y, age: 0 });
start();
};
const listenerOpts = { passive: true } as const;
target.addEventListener(
"pointermove",
track as EventListener,
listenerOpts,
);
target.addEventListener(
"pointerdown",
onDown as EventListener,
listenerOpts,
);
target.addEventListener("pointerleave", release, listenerOpts);
target.addEventListener("pointercancel", release, listenerOpts);
return () => {
stop();
ro.disconnect();
mo.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
window.removeEventListener("scroll", onScroll, { capture: true });
target.removeEventListener("pointermove", track as EventListener);
target.removeEventListener("pointerdown", onDown as EventListener);
target.removeEventListener("pointerleave", release);
target.removeEventListener("pointercancel", release);
};
// Only what changes the grid's identity or the listener target rebuilds
// the loop; everything else is read live from `cfg`.
}, [spacing, color, pointerTarget]);
return (
<div
ref={hostRef}
data-slot="magnetic-dots"
className={cn(
"relative h-full w-full text-zinc-900 dark:text-zinc-100",
className,
)}
{...props}
>
<canvas
ref={canvasRef}
aria-hidden
data-slot="magnetic-dots-canvas"
// pointer-events-none is load-bearing: it's what lets content sit on
// top without stealing the events that drive the field.
className="pointer-events-none absolute inset-0 block h-full w-full"
/>
{children}
</div>
);
}import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/** Merge conditional class names and resolve Tailwind conflicts. */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Shared view-transition name so a card preview morphs into the detail
* page's preview. Must match on both ends; unique per registry entry. */
export function previewTransitionName(name: string) {
return `preview-${name}`;
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
stiffness | number | 90 | Spring constant pulling each dot home. With damping, this is the entire feel of the component — higher is snappier with a faster wobble. |
damping | number | 9 | Velocity damping. Critical is 2·√stiffness; the default sits below it so dots overshoot and settle back visibly. Above it the motion turns to syrup. |
drag | number | 0.9 | How much pointer velocity drags dots along with it. Set to 0 and the field reacts only to cursor position, the way a lerped grid does. |
shockwave | boolean | ShockwaveOptions | true | Expanding ring of force on pointer-down. An analytic annulus rather than a coupled lattice — same look, and it keeps the per-frame cost bounded. |
pointerTarget | "container" | "window" | "container" | Where pointer listeners bind. Container reacts within this element's box; window keeps a full-page backdrop live wherever the cursor is. |
maxDisplacement | number | 46 | Hard cap on how far a dot can leave its grid position. Bounds the dirty rect as well as the chaos; the outward velocity component is stripped at the cap so pinned dots don't snap back. |
strength | number | 2200 | Peak push force at the cursor, in px/s². A force, not a distance — how far a dot actually travels also depends on stiffness and damping. |
radius | number | 170 | Influence radius in CSS pixels. Also sizes the dirty rect, so it — not the grid — determines per-frame cost. |
spacing | number | 22 | Grid pitch in CSS pixels; this drives the dot count. Changing it rebuilds the grid, while everything else retunes the running loop in place. |
attract | boolean | false | Pull dots toward the cursor instead of pushing them away. |
color | string | currentColor | Any canvas-valid color. Defaults to the inherited currentColor, re-resolved when the theme flips since a canvas can't inherit it live. |
onMeasure | (info) => void | — | Fires on layout changes only, never per frame — safe to setState in. Reports dot count, cols, rows, and DPR. |