A field of small dots that behaves like a surface. Rings travel outward from the centre every couple of seconds, and each dot they pass swells, brightens to fuchsia and is nudged outward before settling. A spotlight follows the pointer and lights the dots under it; leave it alone and the light traces a slow figure of eight by itself. Click or tap anywhere to send a new ripple from that point. The hero on top is centred, with a status badge, headline, two buttons and three check cards that stack on phones.
import { useEffect, useRef } from "react";
import { motion, useReducedMotion } from "framer-motion";
// Tune the matrix here.
const MATRIX = {
gap: 22, // distance between dots in pixels
rest: 0.95, // radius of a resting dot
swell: 2.5, // extra radius when a dot is fully lit
spotlight: 190, // radius of the light that follows the pointer
rippleSpeed: 250, // how fast a ring travels, in pixels per second
rippleEvery: 2.3, // seconds between automatic ripples
idleAfter: 2.2, // seconds without pointer movement before the light wanders by itself
base: [96, 96, 108], // resting dot colour (r, g, b)
mid: [217, 70, 239], // lit colour
peak: [250, 232, 255], // colour at the very centre of the light
};
const checks = [
{ name: "api.pulsegrid.dev", region: "Dublin", ms: 82 },
{ name: "checkout", region: "Oregon", ms: 131 },
{ name: "auth", region: "Tokyo", ms: 67 },
];
type Ripple = { x: number; y: number; born: number; strength: number };
export default function DotMatrixBackgroundWithRipplesAndPointerSpotlight() {
const hostRef = useRef<HTMLElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const reduce = useReducedMotion();
useEffect(() => {
const host = hostRef.current;
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!host || !canvas || !ctx) return;
const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let w = 0;
let h = 0;
let raf = 0;
let running = false;
let visible = true;
let last = 0;
let clock = 0;
let nextRipple = 0;
let lastMove = -100;
let ripples: Ripple[] = [];
const light = { x: 0, y: 0, tx: 0, ty: 0 };
const mix = (a: number[], b: number[], t: number) => `rgb(${Math.round(a[0] + (b[0] - a[0]) * t)}, ${Math.round(a[1] + (b[1] - a[1]) * t)}, ${Math.round(a[2] + (b[2] - a[2]) * t)})`;
const draw = (dt: number) => {
clock += dt;
const idle = clock - lastMove > MATRIX.idleAfter;
if (idle) {
// No pointer: the light traces a slow figure of eight.
light.tx = w * (0.5 + Math.sin(clock * 0.55) * 0.34);
light.ty = h * (0.5 + Math.sin(clock * 1.1 + 0.6) * 0.3);
}
const ease = dt ? 1 - Math.exp(-dt * (idle ? 2.5 : 9)) : 1;
light.x += (light.tx - light.x) * ease;
light.y += (light.ty - light.y) * ease;
if (dt) {
nextRipple -= dt;
if (nextRipple <= 0) {
ripples.push({ x: w / 2, y: h * 0.46, born: clock, strength: 1 });
nextRipple = MATRIX.rippleEvery;
}
} else if (!ripples.length) {
ripples.push({ x: w / 2, y: h * 0.46, born: clock - 1.1, strength: 1 }); // a frozen ring for reduced motion
}
const reach = Math.hypot(w, h) * 0.62;
ripples = ripples.filter((r) => (clock - r.born) * MATRIX.rippleSpeed < reach);
ctx.clearRect(0, 0, w, h);
const pool = ctx.createRadialGradient(light.x, light.y, 0, light.x, light.y, MATRIX.spotlight * 1.5);
pool.addColorStop(0, `rgba(${MATRIX.mid.join(",")}, 0.16)`);
pool.addColorStop(1, `rgba(${MATRIX.mid.join(",")}, 0)`);
ctx.fillStyle = pool;
ctx.fillRect(0, 0, w, h);
const gap = MATRIX.gap;
const cols = Math.floor(w / gap) + 1;
const rows = Math.floor(h / gap) + 1;
const ox = (w - (cols - 1) * gap) / 2;
const oy = (h - (rows - 1) * gap) / 2;
const spot2 = MATRIX.spotlight * MATRIX.spotlight;
const baseColor = `rgb(${MATRIX.base.join(",")})`;
// Pass 1: resting dots in one path. Pass 2: lit dots, each with its own colour.
ctx.fillStyle = baseColor;
ctx.globalAlpha = 0.75;
ctx.beginPath();
const lit: number[] = [];
for (let row = 0; row < rows; row++) {
const y = oy + row * gap;
for (let col = 0; col < cols; col++) {
const x = ox + col * gap;
const lx = x - light.x;
const ly = y - light.y;
let glow = Math.exp(-(lx * lx + ly * ly) / spot2);
let pushX = 0;
let pushY = 0;
for (const r of ripples) {
const dx = x - r.x;
const dy = y - r.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const radius = (clock - r.born) * MATRIX.rippleSpeed;
const off = (dist - radius) / 46;
if (off > 3 || off < -3) continue;
const wave = Math.exp(-off * off) * r.strength * Math.max(0, 1 - radius / reach);
glow += wave * 1.05;
pushX += (dx / dist) * wave * 6;
pushY += (dy / dist) * wave * 6;
}
if (glow < 0.04) {
ctx.moveTo(x + MATRIX.rest, y);
ctx.arc(x, y, MATRIX.rest, 0, Math.PI * 2);
} else {
lit.push(x + pushX, y + pushY, Math.min(glow, 1.25));
}
}
}
ctx.fill();
for (let i = 0; i < lit.length; i += 3) {
const glow = lit[i + 2];
const t = Math.min(1, glow);
ctx.globalAlpha = 0.55 + t * 0.45;
ctx.fillStyle = t < 0.6 ? mix(MATRIX.base, MATRIX.mid, t / 0.6) : mix(MATRIX.mid, MATRIX.peak, (t - 0.6) / 0.4);
ctx.beginPath();
ctx.arc(lit[i], lit[i + 1], MATRIX.rest + glow * MATRIX.swell, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
};
const resize = () => {
const rect = host.getBoundingClientRect();
if (Math.abs(rect.width - w) < 1 && Math.abs(rect.height - h) < 1) return;
const first = w === 0;
w = rect.width;
h = rect.height;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (first) {
light.x = w * 0.72;
light.y = h * 0.3;
}
draw(0);
};
const frame = (now: number) => {
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
draw(dt);
raf = requestAnimationFrame(frame);
};
const stop = () => {
running = false;
cancelAnimationFrame(raf);
};
const sync = () => {
if (still || !visible || document.hidden) return stop();
if (running) return;
running = true;
last = performance.now();
raf = requestAnimationFrame(frame);
};
const at = (e: PointerEvent) => {
const rect = host.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
};
const onMove = (e: PointerEvent) => {
const p = at(e);
light.tx = p.x;
light.ty = p.y;
lastMove = clock;
};
const onDown = (e: PointerEvent) => {
const p = at(e);
onMove(e);
if (!still) ripples.push({ x: p.x, y: p.y, born: clock, strength: 1.2 });
};
const onLeave = () => {
lastMove = -100;
};
const ro = new ResizeObserver(resize);
ro.observe(host);
const io = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
sync();
});
io.observe(host);
document.addEventListener("visibilitychange", sync);
host.addEventListener("pointermove", onMove);
host.addEventListener("pointerdown", onDown);
host.addEventListener("pointerleave", onLeave);
resize();
sync();
return () => {
stop();
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", sync);
host.removeEventListener("pointermove", onMove);
host.removeEventListener("pointerdown", onDown);
host.removeEventListener("pointerleave", onLeave);
};
}, []);
const rise = (delay: number) => ({
initial: reduce ? false : { opacity: 0, y: 18, scale: 0.98 },
animate: { opacity: 1, y: 0, scale: 1 },
transition: { type: "spring" as const, stiffness: 110, damping: 18, delay },
});
return (
<section ref={hostRef} className="relative isolate overflow-hidden bg-[#09090b] text-white">
<canvas ref={canvasRef} aria-hidden="true" className="pointer-events-none absolute inset-0 -z-20 h-full w-full" />
{/* Darkens the dots behind the copy and at the edges */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(48%_42%_at_50%_44%,rgba(9,9,11,0.82)_0%,rgba(9,9,11,0.45)_55%,transparent_100%)]" />
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 shadow-[inset_0_0_90px_30px_#09090b]" />
<div className="mx-auto flex min-h-[680px] max-w-4xl flex-col items-center justify-center px-6 py-20 text-center">
<motion.p {...rise(0.05)} className="inline-flex items-center gap-2.5 rounded-full border border-fuchsia-400/30 bg-fuchsia-500/10 px-3.5 py-1.5 font-mono text-xs text-fuchsia-200">
<span className="h-1.5 w-1.5 rounded-full bg-fuchsia-300 shadow-[0_0_10px_2px_rgba(240,171,252,0.8)]" />
1,284 checks running right now
</motion.p>
<motion.h1 {...rise(0.13)} className="mt-7 text-4xl font-semibold leading-[1.04] tracking-tight sm:text-6xl">
Know it is down
<br />
<span className="text-fuchsia-300">before your customers do</span>
</motion.h1>
<motion.p {...rise(0.21)} className="mt-6 max-w-xl text-base leading-relaxed text-zinc-300 sm:text-lg">
Pulsegrid pings every endpoint from 40 cities every 30 seconds and calls the right person the moment something slows down. Move your pointer, or tap anywhere, to send a pulse.
</motion.p>
<motion.div {...rise(0.29)} className="mt-9 flex w-full flex-col items-center justify-center gap-3 sm:w-auto sm:flex-row">
<a href="#" className="inline-flex h-12 w-full items-center justify-center rounded-md bg-fuchsia-300 px-6 text-sm font-semibold text-zinc-950 shadow-[0_0_44px_-10px_rgba(232,121,249,0.9)] transition hover:bg-fuchsia-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-fuchsia-200 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 sm:w-auto">
Start monitoring free
</a>
<a href="#" className="inline-flex h-12 w-full items-center justify-center rounded-md border border-zinc-700 bg-zinc-950/70 px-6 font-mono text-sm text-zinc-200 transition hover:border-zinc-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-fuchsia-200 sm:w-auto">
$ npx pulsegrid init
</a>
</motion.div>
<motion.ul {...rise(0.42)} className="mt-12 grid w-full max-w-2xl gap-2 text-left sm:grid-cols-3">
{checks.map((c) => (
<li key={c.name} className="flex items-center justify-between gap-3 rounded-md border border-zinc-800 bg-zinc-950/95 px-3.5 py-3 sm:block">
<p className="flex min-w-0 items-center gap-2 font-mono text-xs text-zinc-200">
<span className="h-1.5 w-1.5 flex-none rounded-full bg-emerald-400" />
<span className="truncate">{c.name}</span>
</p>
<p className="flex-none font-mono text-xs text-zinc-400 sm:mt-1.5">
{c.region} ยท <span className="tabular-nums text-zinc-200">{c.ms} ms</span>
</p>
</li>
))}
</motion.ul>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/dot-matrix-background-with-ripples-and-pointer-spotlight.json
Each ripple stores where and when it started. Its ring radius is the time since then multiplied by `rippleSpeed`. For every dot the script measures how far the dot is from the ring edge and passes that through a bell curve, so dots on the ring get a value near one and dots a little inside or outside get less. That value drives the size, the colour and a small push away from the centre. Rings weaken as they grow and are removed once they leave the section.
The pointer sets a target position, and each frame the light moves a fraction of the remaining distance toward it. The fraction is based on the frame time, so the feel is the same at 60 and 120 frames a second. When the pointer has not moved for `idleAfter` seconds the target switches to a point on a figure of eight path.
A desktop hero has about 1,800 dots. Resting dots are added to a single path and filled once, and only the lit dots get their own fill, so a frame usually takes about a millisecond. A larger `gap` reduces the work quickly because the count falls with its square.
Yes. Pointer events cover mouse, pen and touch. A tap sends a ripple and moves the light to the finger, and the light goes back to wandering after a short pause.
Yes. An intersection observer and the page visibility event stop and restart the animation frame loop, and all listeners are removed on unmount.
Related: Animated Grid Background with Travelling Light Beams, Particle Network Background with Connecting Lines, Aurora Background with Drifting Colour Bands.
More animated backgrounds or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.