Magnetic Dot Grid
A dot grid that parts around the cursor and springs back.
Customize
<MagneticDotGrid />Changes show live and stay in the address, so a link keeps them.
Code
"use client";
import { useEffect, useRef, useSyncExternalStore, type ReactNode } from "react";
// A full-bleed grid of dots on a 2D canvas. Every dot has a home spot and a spring that pulls it
// back there. Near the cursor the dots are pushed away (or pulled in), swell and take the accent
// colour; when the cursor leaves the springs carry them home with a little overshoot. A click
// sends a shockwave ring through the grid. The loop sleeps when nothing is moving.
export type MagneticDotGridProps = {
/** Distance between dots, in pixels. */
gap?: number;
/** Radius of a dot at rest, in pixels. */
dotSize?: number;
/** How far the cursor reaches, in pixels. */
radius?: number;
/** "repel" pushes the dots away from the cursor, "attract" pulls them in. */
mode?: "repel" | "attract";
/** How hard each dot's spring pulls it home. Higher is snappier. */
stiffness?: number;
/** How quickly the springs stop wobbling. Lower overshoots more. */
damping?: number;
/** The colour dots take near the cursor and in a shockwave. Any CSS colour, including var(--x). */
accentColour?: string;
/** The colour of a dot at rest. "currentColor" follows the text colour around it. */
baseColour?: string;
/** How strong a resting dot's colour is, from 0 to 1, so the grid stays quiet behind content. */
baseOpacity?: number;
/** A click (or tap) sends a ring through the grid. */
shockwave?: boolean;
/** Extra classes for the outer box. Give it a size (it fills it). */
className?: string;
/** Content shown over the grid, e.g. a headline and a button. The grid keeps reacting under it. */
children?: ReactNode;
};
type Dot = { homeX: number; homeY: number; x: number; y: number; vx: number; vy: number; heat: number };
type Ring = { x: number; y: number; age: number };
type Rgb = [number, number, number];
// How many colour steps between base and accent. Dots are drawn in one path per step, which keeps
// a few thousand dots cheap to draw.
const STEPS = 10;
const RING_SPEED = 1100; // px per second
const RING_WIDTH = 80; // px
// prefers-reduced-motion, live
function subscribeToMotion(onChange: () => void) {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}
const prefersReducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Any CSS colour (currentColor and var(--x) included) to [r, g, b]. A hidden probe inside the box
// lets the browser resolve it in place, then the canvas normalises whatever format comes back.
function resolveRgb(probe: HTMLElement, context: CanvasRenderingContext2D, colour: string): Rgb {
probe.style.color = "";
probe.style.color = colour;
const computed = getComputedStyle(probe).color;
context.fillStyle = "#000";
context.fillStyle = computed;
const value = String(context.fillStyle);
if (value.startsWith("#")) {
const n = parseInt(value.slice(1, 7), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const parts = value.match(/[\d.]+/g)?.map(Number) ?? [0, 0, 0];
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
}
// sRGB <-> OKLab. Blending in OKLab keeps the steps between ink and lime clean (an RGB blend runs
// through muddy olive on the way).
const toLinear = (c: number) => {
const v = c / 255;
return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
};
const fromLinear = (v: number) => {
const c = v <= 0.0031308 ? v * 12.92 : 1.055 * Math.max(0, v) ** (1 / 2.4) - 0.055;
return Math.round(Math.min(1, Math.max(0, c)) * 255);
};
function rgbToOklab([r8, g8, b8]: Rgb): Rgb {
const r = toLinear(r8);
const g = toLinear(g8);
const b = toLinear(b8);
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
return [
0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
];
}
function oklabToRgb([L, A, B]: Rgb): Rgb {
const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;
const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;
const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;
return [
fromLinear(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
fromLinear(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
fromLinear(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
];
}
// 0 at the edge of the reach, 1 under the cursor, with soft ends
function falloff(distance: number, reach: number) {
const t = Math.max(0, 1 - distance / reach);
return t * t * (3 - 2 * t);
}
export function MagneticDotGrid({
gap = 24,
dotSize = 1.6,
radius = 160,
mode = "repel",
stiffness = 170,
damping = 13,
accentColour = "#ddfc3e",
baseColour = "currentColor",
baseOpacity = 0.32,
shockwave = true,
className = "",
children,
}: MagneticDotGridProps) {
const boxRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const probeRef = useRef<HTMLSpanElement>(null);
const reducedMotion = useSyncExternalStore(subscribeToMotion, prefersReducedMotion, () => false);
// The loop reads these every frame, so changing a prop never rebuilds the grid
const settings = useRef({ dotSize, radius, mode, stiffness, damping, accentColour, baseColour, baseOpacity, shockwave });
// Set by the effect below: wakes the loop after a prop change
const wakeRef = useRef<() => void>(() => {});
useEffect(() => {
settings.current = { dotSize, radius, mode, stiffness, damping, accentColour, baseColour, baseOpacity, shockwave };
wakeRef.current();
}, [dotSize, radius, mode, stiffness, damping, accentColour, baseColour, baseOpacity, shockwave]);
useEffect(() => {
const box = boxRef.current;
const canvas = canvasRef.current;
const probe = probeRef.current;
const context = canvas?.getContext("2d");
if (!box || !canvas || !probe || !context) return;
const ctx = context;
let dots: Dot[] = [];
let rings: Ring[] = [];
let width = 0;
let height = 0;
let frame = 0;
let running = false;
let visible = true;
let lastTime = 0;
// The biggest change any dot made in the last frame, so the loop can sleep once nothing moves,
// even with the cursor resting on the grid
let motion = { speed: 0, offset: 0, heat: 0 };
// One fill style per colour step, worked out only when a colour, the theme or the size changes
let bucketStyles: string[] = [];
function computeColours() {
const { accentColour: accent, baseColour: base, baseOpacity: opacity } = settings.current;
const from = rgbToOklab(resolveRgb(probe!, ctx, base));
const to = rgbToOklab(resolveRgb(probe!, ctx, accent));
bucketStyles = [];
for (let index = 0; index <= STEPS; index++) {
const t = index / STEPS;
const [r, g, b] = oklabToRgb([from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t, from[2] + (to[2] - from[2]) * t]);
const alpha = opacity + (1 - opacity) * Math.min(1, t * 1.6);
bucketStyles.push(`rgba(${r}, ${g}, ${b}, ${alpha.toFixed(3)})`);
}
}
// The raw pointer, and a smoothed copy the dots actually react to, so fast flicks feel liquid
const pointer = { x: 0, y: 0, active: false };
const follow = { x: 0, y: 0, strength: 0 };
// Lay the dots out on a grid centred in the box, so the margins match on every side
function build() {
const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
width = Math.max(1, box!.clientWidth);
height = Math.max(1, box!.clientHeight);
canvas!.width = Math.round(width * pixelRatio);
canvas!.height = Math.round(height * pixelRatio);
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
computeColours();
const columns = Math.floor(width / gap) + 1;
const rows = Math.floor(height / gap) + 1;
const left = (width - (columns - 1) * gap) / 2;
const top = (height - (rows - 1) * gap) / 2;
dots = [];
for (let row = 0; row < rows; row++) {
for (let column = 0; column < columns; column++) {
const homeX = left + column * gap;
const homeY = top + row * gap;
dots.push({ homeX, homeY, x: homeX, y: homeY, vx: 0, vy: 0, heat: 0 });
}
}
wake();
}
// One physics step of dt seconds
function step(dt: number) {
const { radius: reach, mode: pull, stiffness: k, damping: c } = settings.current;
motion = { speed: 0, offset: 0, heat: 0 };
// The smoothed pointer catches up in about a tenth of a second, and fades in and out
const catchUp = 1 - Math.exp(-dt * 16);
follow.x += (pointer.x - follow.x) * catchUp;
follow.y += (pointer.y - follow.y) * catchUp;
follow.strength += ((pointer.active ? 1 : 0) - follow.strength) * (1 - Math.exp(-dt * 7));
for (const ring of rings) ring.age += dt;
rings = rings.filter((ring) => ring.age * RING_SPEED < Math.hypot(width, height) + RING_WIDTH);
for (const dot of dots) {
let targetX = dot.homeX;
let targetY = dot.homeY;
let warmth = 0;
if (follow.strength > 0.001) {
const dx = dot.homeX - follow.x;
const dy = dot.homeY - follow.y;
const distance = Math.hypot(dx, dy) || 0.0001;
const force = falloff(distance, reach) * follow.strength;
if (force > 0) {
// Repel opens a clearing about a third of the reach wide; attract gathers the dots
// toward the cursor but stops them a little over half a gap short, so they never pile up
const shift = pull === "repel" ? force * reach * 0.34 : -Math.min(force * reach * 0.3, Math.max(0, distance - gap * 0.6));
targetX += (dx / distance) * shift;
targetY += (dy / distance) * shift;
warmth = force;
}
}
// Each ring kicks the dots it passes outward and lights them up, fading as it travels
for (const ring of rings) {
const dx = dot.homeX - ring.x;
const dy = dot.homeY - ring.y;
const distance = Math.hypot(dx, dy) || 0.0001;
const front = ring.age * RING_SPEED;
const band = Math.max(0, 1 - Math.abs(distance - front) / RING_WIDTH);
if (band <= 0) continue;
const fade = Math.max(0, 1 - front / (Math.hypot(width, height) * 0.9));
const kick = band * band * fade * 5200 * dt;
dot.vx += (dx / distance) * kick;
dot.vy += (dy / distance) * kick;
warmth = Math.max(warmth, band * fade);
}
// A damped spring toward the target spot (semi-implicit Euler: stable at these steps)
dot.vx += (k * (targetX - dot.x) - c * dot.vx) * dt;
dot.vy += (k * (targetY - dot.y) - c * dot.vy) * dt;
dot.x += dot.vx * dt;
dot.y += dot.vy * dt;
// Heat rises fast and cools a little slower, so the colour leaves a short afterglow
const rate = warmth > dot.heat ? 14 : 4.5;
dot.heat += (warmth - dot.heat) * (1 - Math.exp(-dt * rate));
motion.speed = Math.max(motion.speed, Math.abs(dot.vx), Math.abs(dot.vy));
motion.offset = Math.max(motion.offset, Math.abs(targetX - dot.x), Math.abs(targetY - dot.y));
motion.heat = Math.max(motion.heat, Math.abs(warmth - dot.heat));
}
}
// Reduced motion: no springs, no rings. The dots stay home and only the colour follows.
function stepStill(dt: number) {
const { radius: reach } = settings.current;
motion = { speed: 0, offset: 0, heat: 0 };
follow.x = pointer.x;
follow.y = pointer.y;
follow.strength += ((pointer.active ? 1 : 0) - follow.strength) * (1 - Math.exp(-dt * 7));
rings = [];
for (const dot of dots) {
dot.x = dot.homeX;
dot.y = dot.homeY;
dot.vx = 0;
dot.vy = 0;
const warmth = falloff(Math.hypot(dot.homeX - follow.x, dot.homeY - follow.y), reach) * follow.strength;
dot.heat += (warmth - dot.heat) * (1 - Math.exp(-dt * 10));
motion.heat = Math.max(motion.heat, Math.abs(warmth - dot.heat));
}
}
// Colour buckets made once and emptied each frame, so long hovers make no garbage
const buckets: Dot[][] = Array.from({ length: STEPS + 1 }, () => []);
function draw() {
const { dotSize: size, mode: pull } = settings.current;
// Attract packs the dots closer, so they swell less there and never overlap
const swell = pull === "attract" ? 1 : 1.6;
ctx.clearRect(0, 0, width, height);
// Sort the dots into colour steps, then draw each step as one path
for (const bucket of buckets) bucket.length = 0;
for (const dot of dots) buckets[Math.min(STEPS, Math.round(dot.heat * STEPS))]!.push(dot);
buckets.forEach((bucket, index) => {
if (bucket.length === 0) return;
const t = index / STEPS;
// Hot dots swell to about 2.6 times their size (not in reduced motion: colour only)
const dotRadius = reducedMotion ? size : size * (1 + t * swell);
ctx.fillStyle = bucketStyles[index] ?? "transparent";
ctx.beginPath();
for (const dot of bucket) {
ctx.moveTo(dot.x + dotRadius, dot.y);
ctx.arc(dot.x, dot.y, dotRadius, 0, Math.PI * 2);
}
ctx.fill();
});
}
// Nothing left to animate: no rings, the smoothed pointer has caught up and no dot moved or
// changed colour last frame. With the cursor gone, every dot must also be home and cool.
function settled() {
if (rings.length > 0) return false;
if (Math.abs(pointer.x - follow.x) + Math.abs(pointer.y - follow.y) > 0.1) return false;
if (Math.abs((pointer.active ? 1 : 0) - follow.strength) > 0.002) return false;
if (motion.speed > 0.5 || motion.offset > 0.05 || motion.heat > 0.01) return false;
if (pointer.active) return true;
for (const dot of dots) {
if (Math.abs(dot.vx) > 0.5 || Math.abs(dot.vy) > 0.5 || Math.abs(dot.x - dot.homeX) > 0.05 || Math.abs(dot.y - dot.homeY) > 0.05 || dot.heat > 0.01) return false;
}
return true;
}
function tick(now: number) {
// Fixed 1/120 s physics steps, so the springs feel the same at 60 Hz and 120 Hz
let elapsed = Math.min((now - lastTime) / 1000, 1 / 20);
lastTime = now;
while (elapsed > 0) {
const dt = Math.min(elapsed, 1 / 120);
if (reducedMotion) stepStill(dt);
else step(dt);
elapsed -= dt;
}
draw();
if (settled()) {
// With the cursor gone, snap the last fractions home so the final frame is exact. With it
// resting on the grid, leave the dots where they are: the next move wakes the loop.
if (!pointer.active) {
for (const dot of dots) {
dot.x = dot.homeX;
dot.y = dot.homeY;
dot.heat = 0;
}
draw();
}
running = false;
return;
}
frame = requestAnimationFrame(tick);
}
function wake() {
if (!visible) return;
if (running) return;
running = true;
lastTime = performance.now();
frame = requestAnimationFrame(tick);
}
// Draw once even when asleep (a new colour or size shows straight away)
function wakeAndDraw() {
computeColours();
draw();
wake();
}
wakeRef.current = wakeAndDraw;
function place(clientX: number, clientY: number) {
const rect = box!.getBoundingClientRect();
pointer.x = clientX - rect.left;
pointer.y = clientY - rect.top;
// The first touch of a visit starts at the finger, not at the old spot
if (follow.strength < 0.01) {
follow.x = pointer.x;
follow.y = pointer.y;
}
pointer.active = true;
wake();
}
// Mouse and pen: pointer events. Touch: touch events, which keep firing while the page scrolls,
// so the grid follows the finger without blocking scroll.
function onPointerMove(event: PointerEvent) {
if (event.pointerType === "touch") return;
place(event.clientX, event.clientY);
}
function onPointerLeave(event: PointerEvent) {
if (event.pointerType === "touch") return;
pointer.active = false;
// The loop may be asleep with the cursor parked: wake it so the dots go home
wake();
}
function onTouch(event: TouchEvent) {
const touch = event.touches[0];
if (touch) place(touch.clientX, touch.clientY);
}
function onTouchEnd(event: TouchEvent) {
if (event.touches.length === 0) {
pointer.active = false;
wake();
}
}
// Click rather than pointerdown, so a finger that starts a scroll does not fire a ring. A click
// made with Enter or Space on a button inside the grid has detail 0 and sits at (0, 0): skip it.
function onClick(event: MouseEvent) {
if (!settings.current.shockwave || reducedMotion || event.detail === 0) return;
const rect = box!.getBoundingClientRect();
rings.push({ x: event.clientX - rect.left, y: event.clientY - rect.top, age: 0 });
if (rings.length > 4) rings.shift();
wake();
}
box.addEventListener("pointermove", onPointerMove);
box.addEventListener("pointerdown", onPointerMove);
box.addEventListener("pointerleave", onPointerLeave);
box.addEventListener("touchstart", onTouch, { passive: true });
box.addEventListener("touchmove", onTouch, { passive: true });
box.addEventListener("touchend", onTouchEnd);
box.addEventListener("touchcancel", onTouchEnd);
box.addEventListener("click", onClick);
// A new size lays the grid out again
const resizer = new ResizeObserver(build);
resizer.observe(box);
// No work while the box is off screen
const visibility = new IntersectionObserver(([entry]) => {
visible = entry?.isIntersecting ?? true;
if (visible) wakeAndDraw();
else {
cancelAnimationFrame(frame);
running = false;
}
});
visibility.observe(box);
// "currentColor" changes with the site theme, so redraw when the theme flips
const themeWatcher = new MutationObserver(wakeAndDraw);
themeWatcher.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-theme", "style"] });
const scheme = window.matchMedia("(prefers-color-scheme: dark)");
scheme.addEventListener("change", wakeAndDraw);
build();
return () => {
cancelAnimationFrame(frame);
running = false;
wakeRef.current = () => {};
resizer.disconnect();
visibility.disconnect();
themeWatcher.disconnect();
scheme.removeEventListener("change", wakeAndDraw);
box.removeEventListener("pointermove", onPointerMove);
box.removeEventListener("pointerdown", onPointerMove);
box.removeEventListener("pointerleave", onPointerLeave);
box.removeEventListener("touchstart", onTouch);
box.removeEventListener("touchmove", onTouch);
box.removeEventListener("touchend", onTouchEnd);
box.removeEventListener("touchcancel", onTouchEnd);
box.removeEventListener("click", onClick);
};
}, [gap, reducedMotion]);
return (
<div ref={boxRef} className={`relative h-full w-full overflow-hidden ${className}`}>
<canvas ref={canvasRef} aria-hidden="true" className="pointer-events-none absolute inset-0 block h-full w-full" />
{/* Resolves the colour props (currentColor, var(--x)) in place */}
<span ref={probeRef} aria-hidden="true" className="pointer-events-none absolute h-0 w-0 overflow-hidden" />
{children !== undefined && <div className="relative h-full w-full">{children}</div>}
</div>
);
}
The component. Installs to components/dimaac/magnetic-dot-grid.tsx.
Install
With the shadcn CLI
npx shadcn@latest add https://ui.dimaac.com/r/magnetic-dot-grid.jsonIt adds components/dimaac/magnetic-dot-grid.tsx and installs what it imports. With the @dimaac namespace in components.json (Docs) it's just npx shadcn@latest add @dimaac/magnetic-dot-grid.
By hand
# Nothing to install: it only needs React- It needs nothing but React 19.
- Copy the component file below into components/dimaac/magnetic-dot-grid.tsx.
- It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
- Import it with import { MagneticDotGrid } from "@/components/dimaac/magnetic-dot-grid" and give its parent a size: it fills it.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
gap | number | 24 | Distance between dots, in pixels. |
dotSize | number | 1.6 | Radius of a dot at rest, in pixels. |
radius | number | 160 | How far the cursor reaches, in pixels. |
mode | "repel" | "attract" | "repel" | "repel" pushes the dots away from the cursor, "attract" pulls them in. |
stiffness | number | 170 | How hard each dot's spring pulls it home. Higher is snappier. |
damping | number | 13 | How quickly the springs stop wobbling. Lower overshoots more. |
accentColour | string | "#ddfc3e" | The colour dots take near the cursor and in a shockwave. Any CSS colour, including var(--x). |
baseColour | string | "currentColor" | The colour of a dot at rest. "currentColor" follows the text colour around it. |
baseOpacity | number | 0.32 | How strong a resting dot's colour is, from 0 to 1, so the grid stays quiet behind content. |
shockwave | boolean | true | A click (or tap) sends a ring through the grid. |
className | string | "" | Extra classes for the outer box. Give it a size (it fills it). |
children | ReactNode | - | Content shown over the grid, e.g. a headline and a button. The grid keeps reacting under it. |
Use with AI
Agent prompt
Paste it into Claude Code, Cursor or any coding agent, or open it straight in a chat. It carries your Customize settings.
Add the Magnetic Dot Grid component (<MagneticDotGrid />) from DiMaac UI to this project.
Docs: https://ui.dimaac.com/components/magnetic-dot-grid
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/magnetic-dot-grid.json
Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/magnetic-dot-grid.json
Then use it like this:
import { MagneticDotGrid } from "@/components/dimaac/magnetic-dot-grid";
<MagneticDotGrid />
If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/magnetic-dot-grid.tsx.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txtFiles agents read
- llms.txt
- Every component and how to install it
- magnetic-dot-grid.md
- This page as Markdown: install, props and the full source
- magnetic-dot-grid.json
- The shadcn registry item
About
A full-bleed grid of small dots drawn on one 2D canvas. Near the cursor the dots are pushed away (or pulled in), swell and take the accent colour, and when it leaves a damped spring carries each one home with a soft overshoot. A click sends a shockwave ring through the whole grid. Resting dots follow the text colour, so the grid retunes with the theme. It is sharp on retina screens, lays itself out again on resize, sleeps when nothing is moving or the box is off screen, follows the finger on touch screens without blocking scroll, and only changes colour (no motion) when the visitor prefers reduced motion. No libraries.
- Installs to
components/dimaac/magnetic-dot-grid.tsx- Uses
- React only
- Tags
- canvas, cursor, background, physics, hero