Proximity dot grid
A canvas of small dots that grow and turn teal near the pointer, with a slow wave when nobody is pointing.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useEffect, useRef } from "react";
import { useInView, usePrefersReducedMotion, tokenColor } from "@/lib/motion";
import styles from "./demo.module.css";
const GAP = 22;
const BASE = 1.4;
const GROW = 3.4;
const REACH = 120;
export default function Demo() {
const root = useRef<HTMLDivElement>(null);
const canvas = useRef<HTMLCanvasElement>(null);
const inView = useInView(root);
const reduced = usePrefersReducedMotion();
useEffect(() => {
const el = root.current;
const cv = canvas.current;
const ctx = cv?.getContext("2d");
if (!el || !cv || !ctx) return;
const dim = tokenColor(el, "--muted");
const tint = tokenColor(el, "--accent");
let width = 0;
let height = 0;
const target = { x: 0, y: 0 };
const pos = { x: 0, y: 0 };
let presence = 0;
let hovering = false;
let raf = 0;
const draw = (time: number) => {
ctx.clearRect(0, 0, width, height);
const cols = Math.ceil(width / GAP);
const rows = Math.ceil(height / GAP);
const ox = (width - (cols - 1) * GAP) / 2;
const oy = (height - (rows - 1) * GAP) / 2;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const x = ox + c * GAP;
const y = oy + r * GAP;
// Idle: a slow diagonal swell. Pointer: a falloff around the smoothed pointer.
const wave = reduced ? 0 : Math.pow(0.5 + 0.5 * Math.sin(x * 0.018 + y * 0.012 - time * 0.0016), 6) * 0.45;
const near = Math.max(0, 1 - Math.hypot(x - pos.x, y - pos.y) / REACH);
const t = wave * (1 - presence) + near * near * presence;
ctx.globalAlpha = 0.35;
ctx.fillStyle = dim;
ctx.beginPath();
ctx.arc(x, y, BASE + GROW * t, 0, Math.PI * 2);
ctx.fill();
if (t > 0.02) {
ctx.globalAlpha = Math.min(1, t * 1.4);
ctx.fillStyle = tint;
ctx.fill();
}
}
}
ctx.globalAlpha = 1;
};
const frame = (time: number) => {
pos.x += (target.x - pos.x) * 0.2;
pos.y += (target.y - pos.y) * 0.2;
presence += ((hovering ? 1 : 0) - presence) * 0.08;
draw(time);
raf = requestAnimationFrame(frame);
};
const resize = () => {
const dpr = Math.min(devicePixelRatio || 1, 2);
width = el.clientWidth;
height = el.clientHeight;
cv.width = Math.round(width * dpr);
cv.height = Math.round(height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (reduced || !inView) draw(0);
};
const observer = new ResizeObserver(resize);
observer.observe(el);
const move = (event: PointerEvent) => {
if (event.pointerType === "touch") return;
const rect = el.getBoundingClientRect();
target.x = event.clientX - rect.left;
target.y = event.clientY - rect.top;
if (!hovering) {
pos.x = target.x;
pos.y = target.y;
}
hovering = true;
};
const leave = () => (hovering = false);
el.addEventListener("pointermove", move);
el.addEventListener("pointerleave", leave);
if (inView && !reduced) raf = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(raf);
observer.disconnect();
el.removeEventListener("pointermove", move);
el.removeEventListener("pointerleave", leave);
};
}, [inView, reduced]);
return (
<div ref={root} className={styles.root}>
<canvas ref={canvas} className={styles.canvas} aria-hidden="true" />
<div className={styles.copy}>
<b>Every night on one map</b>
<span>Move across the grid</span>
</div>
</div>
);
}
demo.module.css
.root { position: relative; width: 100%; height: 100%; display: grid; place-items: center; overflow: hidden; }
.canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.copy { position: relative; display: grid; gap: 2px; padding: 12px 18px; border-radius: 14px; background: color-mix(in srgb, var(--surface) 85%, transparent); text-align: center; pointer-events: none; }
.copy b { font-size: 17px; letter-spacing: -0.01em; }
.copy span { font-size: 13px; color: var(--muted); }
Use it for
- Quiet hero or section backgrounds that reward exploring
- Empty states that should feel alive without content
Avoid
- Behind body text, where moving dots hurt reading
- Pages with several canvases already running
How it works
A requestAnimationFrame loop draws every dot on a 2D canvas. Each dot's size and teal alpha come from a falloff on its distance to a smoothed pointer position. A presence value eases between the pointer falloff and an idle sine wave, so leaving the area blends back into the wave. Colours are read from the CSS tokens once.
Accessibility
The canvas is aria-hidden decoration. The loop only runs while the grid is on screen. Touch input is ignored so scrolling is never hijacked, and touch users see the idle wave. Reduced motion draws one static frame.