dotgrid
Full-viewport canvas dot grid that drifts toward the cursor; pauses when the tab is hidden, off under reduced motion.
canvasbackgroundmotiondecoration
Preview
Move the cursor — nearby dots brighten and drift toward it.
Installation
shadcn CLI
Terminal
npx shadcn@latest add https://mocoui.site/r/dotgrid.jsonManual
Copy each file from the Source section into its target path:
registry/dotgrid/dotgrid.tsx → components/moco/dotgrid.tsx
registry/dotgrid/dotgrid.css → components/moco/dotgrid.cssUsage
usage.tsx
import { DotGrid } from "./dotgrid";
export default function Demo() {
return (
<div style={{ minHeight: "60vh" }}>
<DotGrid />
<p style={{ position: "relative", zIndex: 1, padding: "2rem" }}>
Move the cursor — nearby dots brighten and drift toward it.
</p>
</div>
);
}
Source
components/moco/dotgrid.tsx
"use client";
import * as React from "react";
import { useReducedMotion } from "@/components/moco/hooks";
import "./dotgrid.css";
export interface DotGridProps {
/** Grid pitch in px. */
spacing?: number;
/** Cursor influence radius in px. */
radius?: number;
}
/**
* Sparse dot grid that reacts to the cursor. Pauses when the tab is hidden and
* does not run at all under prefers-reduced-motion. Dot color is resolved from
* the canvas's computed `color` (set to var(--moco-ink) in dotgrid.css), so it
* stays visible in dark mode; only the alpha ramp is applied on top.
*/
export function DotGrid({ spacing = 34, radius = 130 }: DotGridProps) {
const ref = React.useRef<HTMLCanvasElement>(null);
const reduced = useReducedMotion();
React.useEffect(() => {
if (reduced) return;
const canvas = ref.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return;
let w = 0;
let h = 0;
let dpr = 1;
const pointer = { x: -9999, y: -9999 };
let raf = 0;
let running = true;
// Base dot color, re-resolved on color-scheme change and resize.
let rgb = "0,0,0";
const resolveColor = () => {
const m = getComputedStyle(canvas).color.match(/[\d.]+/g);
if (m && m.length >= 3) rgb = `${m[0]},${m[1]},${m[2]}`;
};
const resize = () => {
dpr = Math.min(2, window.devicePixelRatio || 1);
w = canvas.clientWidth;
h = canvas.clientHeight;
canvas.width = Math.floor(w * dpr);
canvas.height = Math.floor(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
resolveColor();
};
const draw = () => {
ctx.clearRect(0, 0, w, h);
for (let x = spacing / 2; x < w; x += spacing) {
for (let y = spacing / 2; y < h; y += spacing) {
const dx = x - pointer.x;
const dy = y - pointer.y;
const d = Math.hypot(dx, dy);
const k = d < radius ? 1 - d / radius : 0;
const alpha = 0.07 + k * 0.26;
const push = k * 4;
const px = x + (d ? (dx / d) * push : 0);
const py = y + (d ? (dy / d) * push : 0);
ctx.fillStyle = `rgba(${rgb},${alpha.toFixed(3)})`;
ctx.fillRect(px, py, 1, 1);
}
}
if (running) raf = requestAnimationFrame(draw);
};
const onMove = (e: PointerEvent) => {
pointer.x = e.clientX;
pointer.y = e.clientY;
};
const onLeave = () => {
pointer.x = -9999;
pointer.y = -9999;
};
const onVis = () => {
running = !document.hidden;
if (running) raf = requestAnimationFrame(draw);
else cancelAnimationFrame(raf);
};
const scheme = window.matchMedia("(prefers-color-scheme: dark)");
scheme.addEventListener("change", resolveColor);
resize();
raf = requestAnimationFrame(draw);
window.addEventListener("resize", resize);
window.addEventListener("pointermove", onMove, { passive: true });
window.addEventListener("pointerleave", onLeave);
document.addEventListener("visibilitychange", onVis);
return () => {
running = false;
cancelAnimationFrame(raf);
scheme.removeEventListener("change", resolveColor);
window.removeEventListener("resize", resize);
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerleave", onLeave);
document.removeEventListener("visibilitychange", onVis);
};
}, [spacing, radius, reduced]);
return <canvas ref={ref} className="moco-dotgrid" aria-hidden="true" />;
}
components/moco/dotgrid.css
.moco-dotgrid {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
/* Not painted by CSS — the canvas reads its computed `color` at runtime
for dot fill, so dots follow the ink token in light and dark. */
color: var(--moco-ink);
}