Dot Field
BackgroundsA canvas dot grid that reacts around the cursor, at a density SVG can't reach. The per-frame cost is bounded by the influence radius rather than the dot count: only the dirty rect is cleared and redrawn, and dots are batched into 16 paths by quantised alpha instead of one fill each. DPR-correct on retina, and the rAF loop is genuinely cancelled — offscreen, backgrounded, or simply settled.
0 dotsDPR 1hover to measure
Spacing · 10px
Radius · 140px
Mode
Direction
Drop spacing to 8 or below to push past 5,000 dots and watch the fps hold — the per-frame cost is bounded by the influence radius, not the grid size. Scroll the field out of view and the rAF loop is cancelled outright, not left spinning.
components/ui/dot-field.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* DotField — a canvas dot grid that reacts around the cursor, at a density SVG
* can't reach.
*
* Everything here is a performance decision. The effect is trivial; holding
* 60fps at 5,000+ dots is the component.
*
* ## Where the frame budget actually goes
*
* The obvious loop — clear the canvas, test every dot against the cursor,
* redraw every dot — spends almost nothing on the distance tests and almost
* everything on **draw calls**. 5,000 × (`beginPath` + `arc` + `fill`) is what
* misses the frame, not 5,000 squared-distance comparisons. So both wins that
* matter are about drawing less:
*
* **1 · Dirty-rect rendering.** A canvas is persistent, so only the region that
* changed needs clearing. Each frame clears the union of the cursor's influence
* rect and the *wake* — the bounding box of dots still settling from the last
* frame — and redraws only that. Dots outside it are left alone; their pixels
* are already correct. At the default radius that is ~300 dots redrawn instead
* of 5,000, and the per-frame cost stops scaling with grid size entirely.
*
* **2 · Batched paths.** Inside the dirty region, dots are bucketed by
* quantised alpha and each bucket is drawn as **one** path with a single
* `fill()` — 16 fills per frame regardless of dot count, rather than one per
* dot. Quantising alpha to 16 levels is the tradeoff that makes the batching
* possible; at these sizes the banding is not perceptible.
*
* ## Spatial partitioning
*
* For a *uniform grid* the grid already is the spatial hash, and a better one:
* the affected index window is `floor((rect − origin) / spacing)` per axis —
* O(1), no build cost, no memory, no hashing, and nothing to rebuild when the
* grid resizes. Offsets live in a flat `Float32Array` indexed `row · cols +
* col`, so walking the window is a contiguous-ish submatrix scan.
*
* The wake is tracked in **base** coordinates, not drawn ones. A dot displaced
* to the edge of the wake has its grid position up to `strength` px away, so a
* bbox of drawn positions would map back to an index window that misses the
* dot it was drawn for — it would then never be repainted and would smear.
*
* ## The rAF loop genuinely stops
*
* Not "runs and does nothing" — it is cancelled and not rescheduled. Three
* things stop it: nothing left to draw (no pointer, empty wake),
* `IntersectionObserver` reporting it offscreen, and `visibilitychange` for
* background tabs. Pointer events restart it. An always-on rAF loop in a
* registry component is a battery bug someone else inherits.
*
* ## Other details that matter
*
* - **DPR** — the backing store is sized to `devicePixelRatio` and the context
* scaled, or it renders soft on every retina display. Capped at 2: some
* Android devices report 3+, which is 2.25× the fill rate for no visible gain.
* - **`ResizeObserver`, not `window.resize`** — the container can change size
* without the window doing anything.
* - Smoothing is `1 − exp(−dt/τ)`, so the settle is frame-rate independent
* rather than "however fast this machine happens to run".
* - Reduced motion paints the static grid once and never starts the loop — a
* still grid, not a blank canvas.
* - Monochrome by default: the ink resolves from the inherited `currentColor`,
* re-read when the theme attribute flips, because a canvas cannot inherit it
* live the way SVG does.
*/
export type DotFieldMode = "displace" | "scale" | "brighten";
/** 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;
/** Below this a dot counts as settled and drops out of the wake. */
const SETTLED = 0.004;
/** 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;
/** Sensible peak per mode — px of push, radius multiplier, alpha multiplier. */
const DEFAULT_STRENGTH: Record<DotFieldMode, number> = {
displace: 14,
scale: 3,
brighten: 2.8,
};
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 LiveConfig {
dotSize: number;
radius: number;
mode: DotFieldMode;
strength: number;
attract: boolean;
falloff: (t: number) => number;
opacity: number;
smoothing: number;
}
export interface DotFieldProps 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;
/** What the cursor does to nearby dots. */
mode?: DotFieldMode;
/**
* Peak effect at the cursor. Units depend on `mode`: pixels of push for
* `displace`, radius multiplier for `scale`, alpha multiplier for
* `brighten`. Defaults per mode rather than to one number, since 14px of
* push and a 14× radius are not the same request.
*/
strength?: number;
/** Pull dots toward the cursor instead of pushing them away. */
attract?: boolean;
/** 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;
/** Settle time constant in seconds. Larger = dots trail the cursor longer. */
smoothing?: number;
/** 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 DotField({
spacing = 18,
dotSize = 1.2,
radius = 140,
mode = "displace",
strength,
attract = false,
falloff = defaultFalloff,
opacity = 0.35,
color,
smoothing = 0.09,
onMeasure,
className,
...props
}: DotFieldProps) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const resolvedStrength = strength ?? DEFAULT_STRENGTH[mode];
// 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,
mode,
strength: resolvedStrength,
attract,
falloff,
opacity,
smoothing,
});
const onMeasureRef = React.useRef(onMeasure);
React.useEffect(() => {
cfg.current = {
dotSize,
radius,
mode,
strength: resolvedStrength,
attract,
falloff,
opacity,
smoothing,
};
onMeasureRef.current = onMeasure;
});
React.useEffect(() => {
const canvas = canvasRef.current;
if (!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;
/** Current (dx, dy) per dot, interleaved. Rest is 0. */
let offsets = new Float32Array(0);
let ink = "#000";
// --- pointer ("no pointer" is the Infinity sentinel) -------------------
let px = Infinity;
let py = Infinity;
// --- loop -------------------------------------------------------------
let raf = 0;
let running = false;
let onScreen = true;
let last = 0;
let smoothK = 1;
/** Bbox (in base coordinates) of dots still settling. */
let wake: Rect | null = null;
const buckets: number[][] = Array.from({ length: ALPHA_BUCKETS }, () => []);
const resolveInk = () => {
ink = color ?? getComputedStyle(canvas).color ?? "#000";
};
/** How far a dot's drawn pixels can stray from its grid position. */
const reach = (c: LiveConfig) =>
(c.mode === "displace" ? c.strength : 0) +
(c.mode === "scale" ? c.dotSize * Math.max(1, c.strength) : c.dotSize) +
2;
/**
* Draw every dot in an index window at its current position, bucketed by
* alpha so each bucket costs one path and one fill. Returns the bbox of
* dots that are still moving, in base coordinates.
*/
const drawWindow = (c0: number, c1: number, r0: number, r1: number) => {
const c = cfg.current;
const hasPointer = Number.isFinite(px);
const r2 = c.radius * c.radius;
const pad = reach(c);
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;
let intensity = 0;
let dirX = 0;
let dirY = 0;
if (hasPointer) {
const dx = bx - px;
const dy = by - py;
const d2 = dx * dx + dy * dy;
if (d2 < r2) {
const d = Math.sqrt(d2) || 1e-4;
const t = c.falloff(d / c.radius);
intensity = t < 0 ? 0 : t > 1 ? 1 : t;
dirX = dx / d;
dirY = dy / d;
}
}
// Every mode routes through the same two smoothed slots, so they all
// settle with identical timing. `displace` uses both; the others
// carry intensity in the first and leave the second at rest.
const sign = c.attract ? -1 : 1;
const isDisplace = c.mode === "displace";
const targetX = isDisplace
? dirX * c.strength * intensity * sign
: intensity;
const targetY = isDisplace ? dirY * c.strength * intensity * sign : 0;
const ox = (offsets[i * 2] += (targetX - offsets[i * 2]) * smoothK);
const oy = (offsets[i * 2 + 1] +=
(targetY - offsets[i * 2 + 1]) * smoothK);
let x = bx;
let y = by;
let rad = c.dotSize;
let alpha = c.opacity;
if (isDisplace) {
x += ox;
y += oy;
} else if (c.mode === "scale") {
rad = c.dotSize * (1 + (c.strength - 1) * ox);
} else {
alpha = Math.min(1, c.opacity * (1 + (c.strength - 1) * ox));
}
if (rad > 0.05 && alpha > 1 / (ALPHA_BUCKETS * 4)) {
const b = Math.min(
ALPHA_BUCKETS - 1,
Math.max(0, Math.floor(alpha * ALPHA_BUCKETS)),
);
buckets[b].push(x, y, rad);
}
if (Math.abs(ox) > SETTLED || Math.abs(oy) > SETTLED) {
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)),
});
const paintAll = () => {
if (cols === 0 || rows === 0) return;
ctx.clearRect(0, 0, cssW, cssH);
smoothK = 1;
wake = drawWindow(0, cols - 1, 0, rows - 1);
};
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 frame = (now: number) => {
const dt = last === 0 ? 1 / 60 : Math.min((now - last) / 1000, 0.064);
last = now;
smoothK = 1 - Math.exp(-dt / Math.max(0.001, cfg.current.smoothing));
const dirty = union(influenceRect(), wake);
if (!dirty) {
// Nothing to draw and nothing settling — actually stop, don't idle.
running = false;
last = 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);
raf = requestAnimationFrame(frame);
};
const start = () => {
if (running || reduce || !onScreen || document.hidden) return;
running = true;
last = 0;
raf = requestAnimationFrame(frame);
};
const stop = () => {
if (!running) return;
running = false;
cancelAnimationFrame(raf);
last = 0;
};
// --- layout -----------------------------------------------------------
const layout = () => {
const rect = canvas.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
cssW = rect.width;
cssH = rect.height;
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;
offsets = new Float32Array(cols * rows * 2);
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);
const track = (e: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
px = e.clientX - rect.left;
py = e.clientY - rect.top;
start();
};
const release = () => {
px = Infinity;
py = Infinity;
start();
};
canvas.addEventListener("pointermove", track);
canvas.addEventListener("pointerdown", track);
canvas.addEventListener("pointerleave", release);
canvas.addEventListener("pointercancel", release);
return () => {
stop();
ro.disconnect();
mo.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
canvas.removeEventListener("pointermove", track);
canvas.removeEventListener("pointerdown", track);
canvas.removeEventListener("pointerleave", release);
canvas.removeEventListener("pointercancel", release);
};
// Only the two props that change the grid's identity rebuild the loop;
// everything else is read live from `cfg`.
}, [spacing, color]);
return (
<div
data-slot="dot-field"
aria-hidden
className={cn(
"relative h-full w-full text-zinc-900 dark:text-zinc-100",
className,
)}
{...props}
>
<canvas
ref={canvasRef}
data-slot="dot-field-canvas"
className="absolute inset-0 block h-full w-full touch-pan-y"
/>
</div>
);
}Installation
Terminal
npx shadcn@latest add https://ui.saumyarex.xyz/r/dot-field.json1. Install dependencies
Terminal
npm install clsx tailwind-merge2. Copy the source into your project
components/ui/dot-field.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* DotField — a canvas dot grid that reacts around the cursor, at a density SVG
* can't reach.
*
* Everything here is a performance decision. The effect is trivial; holding
* 60fps at 5,000+ dots is the component.
*
* ## Where the frame budget actually goes
*
* The obvious loop — clear the canvas, test every dot against the cursor,
* redraw every dot — spends almost nothing on the distance tests and almost
* everything on **draw calls**. 5,000 × (`beginPath` + `arc` + `fill`) is what
* misses the frame, not 5,000 squared-distance comparisons. So both wins that
* matter are about drawing less:
*
* **1 · Dirty-rect rendering.** A canvas is persistent, so only the region that
* changed needs clearing. Each frame clears the union of the cursor's influence
* rect and the *wake* — the bounding box of dots still settling from the last
* frame — and redraws only that. Dots outside it are left alone; their pixels
* are already correct. At the default radius that is ~300 dots redrawn instead
* of 5,000, and the per-frame cost stops scaling with grid size entirely.
*
* **2 · Batched paths.** Inside the dirty region, dots are bucketed by
* quantised alpha and each bucket is drawn as **one** path with a single
* `fill()` — 16 fills per frame regardless of dot count, rather than one per
* dot. Quantising alpha to 16 levels is the tradeoff that makes the batching
* possible; at these sizes the banding is not perceptible.
*
* ## Spatial partitioning
*
* For a *uniform grid* the grid already is the spatial hash, and a better one:
* the affected index window is `floor((rect − origin) / spacing)` per axis —
* O(1), no build cost, no memory, no hashing, and nothing to rebuild when the
* grid resizes. Offsets live in a flat `Float32Array` indexed `row · cols +
* col`, so walking the window is a contiguous-ish submatrix scan.
*
* The wake is tracked in **base** coordinates, not drawn ones. A dot displaced
* to the edge of the wake has its grid position up to `strength` px away, so a
* bbox of drawn positions would map back to an index window that misses the
* dot it was drawn for — it would then never be repainted and would smear.
*
* ## The rAF loop genuinely stops
*
* Not "runs and does nothing" — it is cancelled and not rescheduled. Three
* things stop it: nothing left to draw (no pointer, empty wake),
* `IntersectionObserver` reporting it offscreen, and `visibilitychange` for
* background tabs. Pointer events restart it. An always-on rAF loop in a
* registry component is a battery bug someone else inherits.
*
* ## Other details that matter
*
* - **DPR** — the backing store is sized to `devicePixelRatio` and the context
* scaled, or it renders soft on every retina display. Capped at 2: some
* Android devices report 3+, which is 2.25× the fill rate for no visible gain.
* - **`ResizeObserver`, not `window.resize`** — the container can change size
* without the window doing anything.
* - Smoothing is `1 − exp(−dt/τ)`, so the settle is frame-rate independent
* rather than "however fast this machine happens to run".
* - Reduced motion paints the static grid once and never starts the loop — a
* still grid, not a blank canvas.
* - Monochrome by default: the ink resolves from the inherited `currentColor`,
* re-read when the theme attribute flips, because a canvas cannot inherit it
* live the way SVG does.
*/
export type DotFieldMode = "displace" | "scale" | "brighten";
/** 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;
/** Below this a dot counts as settled and drops out of the wake. */
const SETTLED = 0.004;
/** 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;
/** Sensible peak per mode — px of push, radius multiplier, alpha multiplier. */
const DEFAULT_STRENGTH: Record<DotFieldMode, number> = {
displace: 14,
scale: 3,
brighten: 2.8,
};
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 LiveConfig {
dotSize: number;
radius: number;
mode: DotFieldMode;
strength: number;
attract: boolean;
falloff: (t: number) => number;
opacity: number;
smoothing: number;
}
export interface DotFieldProps 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;
/** What the cursor does to nearby dots. */
mode?: DotFieldMode;
/**
* Peak effect at the cursor. Units depend on `mode`: pixels of push for
* `displace`, radius multiplier for `scale`, alpha multiplier for
* `brighten`. Defaults per mode rather than to one number, since 14px of
* push and a 14× radius are not the same request.
*/
strength?: number;
/** Pull dots toward the cursor instead of pushing them away. */
attract?: boolean;
/** 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;
/** Settle time constant in seconds. Larger = dots trail the cursor longer. */
smoothing?: number;
/** 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 DotField({
spacing = 18,
dotSize = 1.2,
radius = 140,
mode = "displace",
strength,
attract = false,
falloff = defaultFalloff,
opacity = 0.35,
color,
smoothing = 0.09,
onMeasure,
className,
...props
}: DotFieldProps) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const resolvedStrength = strength ?? DEFAULT_STRENGTH[mode];
// 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,
mode,
strength: resolvedStrength,
attract,
falloff,
opacity,
smoothing,
});
const onMeasureRef = React.useRef(onMeasure);
React.useEffect(() => {
cfg.current = {
dotSize,
radius,
mode,
strength: resolvedStrength,
attract,
falloff,
opacity,
smoothing,
};
onMeasureRef.current = onMeasure;
});
React.useEffect(() => {
const canvas = canvasRef.current;
if (!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;
/** Current (dx, dy) per dot, interleaved. Rest is 0. */
let offsets = new Float32Array(0);
let ink = "#000";
// --- pointer ("no pointer" is the Infinity sentinel) -------------------
let px = Infinity;
let py = Infinity;
// --- loop -------------------------------------------------------------
let raf = 0;
let running = false;
let onScreen = true;
let last = 0;
let smoothK = 1;
/** Bbox (in base coordinates) of dots still settling. */
let wake: Rect | null = null;
const buckets: number[][] = Array.from({ length: ALPHA_BUCKETS }, () => []);
const resolveInk = () => {
ink = color ?? getComputedStyle(canvas).color ?? "#000";
};
/** How far a dot's drawn pixels can stray from its grid position. */
const reach = (c: LiveConfig) =>
(c.mode === "displace" ? c.strength : 0) +
(c.mode === "scale" ? c.dotSize * Math.max(1, c.strength) : c.dotSize) +
2;
/**
* Draw every dot in an index window at its current position, bucketed by
* alpha so each bucket costs one path and one fill. Returns the bbox of
* dots that are still moving, in base coordinates.
*/
const drawWindow = (c0: number, c1: number, r0: number, r1: number) => {
const c = cfg.current;
const hasPointer = Number.isFinite(px);
const r2 = c.radius * c.radius;
const pad = reach(c);
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;
let intensity = 0;
let dirX = 0;
let dirY = 0;
if (hasPointer) {
const dx = bx - px;
const dy = by - py;
const d2 = dx * dx + dy * dy;
if (d2 < r2) {
const d = Math.sqrt(d2) || 1e-4;
const t = c.falloff(d / c.radius);
intensity = t < 0 ? 0 : t > 1 ? 1 : t;
dirX = dx / d;
dirY = dy / d;
}
}
// Every mode routes through the same two smoothed slots, so they all
// settle with identical timing. `displace` uses both; the others
// carry intensity in the first and leave the second at rest.
const sign = c.attract ? -1 : 1;
const isDisplace = c.mode === "displace";
const targetX = isDisplace
? dirX * c.strength * intensity * sign
: intensity;
const targetY = isDisplace ? dirY * c.strength * intensity * sign : 0;
const ox = (offsets[i * 2] += (targetX - offsets[i * 2]) * smoothK);
const oy = (offsets[i * 2 + 1] +=
(targetY - offsets[i * 2 + 1]) * smoothK);
let x = bx;
let y = by;
let rad = c.dotSize;
let alpha = c.opacity;
if (isDisplace) {
x += ox;
y += oy;
} else if (c.mode === "scale") {
rad = c.dotSize * (1 + (c.strength - 1) * ox);
} else {
alpha = Math.min(1, c.opacity * (1 + (c.strength - 1) * ox));
}
if (rad > 0.05 && alpha > 1 / (ALPHA_BUCKETS * 4)) {
const b = Math.min(
ALPHA_BUCKETS - 1,
Math.max(0, Math.floor(alpha * ALPHA_BUCKETS)),
);
buckets[b].push(x, y, rad);
}
if (Math.abs(ox) > SETTLED || Math.abs(oy) > SETTLED) {
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)),
});
const paintAll = () => {
if (cols === 0 || rows === 0) return;
ctx.clearRect(0, 0, cssW, cssH);
smoothK = 1;
wake = drawWindow(0, cols - 1, 0, rows - 1);
};
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 frame = (now: number) => {
const dt = last === 0 ? 1 / 60 : Math.min((now - last) / 1000, 0.064);
last = now;
smoothK = 1 - Math.exp(-dt / Math.max(0.001, cfg.current.smoothing));
const dirty = union(influenceRect(), wake);
if (!dirty) {
// Nothing to draw and nothing settling — actually stop, don't idle.
running = false;
last = 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);
raf = requestAnimationFrame(frame);
};
const start = () => {
if (running || reduce || !onScreen || document.hidden) return;
running = true;
last = 0;
raf = requestAnimationFrame(frame);
};
const stop = () => {
if (!running) return;
running = false;
cancelAnimationFrame(raf);
last = 0;
};
// --- layout -----------------------------------------------------------
const layout = () => {
const rect = canvas.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
cssW = rect.width;
cssH = rect.height;
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;
offsets = new Float32Array(cols * rows * 2);
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);
const track = (e: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
px = e.clientX - rect.left;
py = e.clientY - rect.top;
start();
};
const release = () => {
px = Infinity;
py = Infinity;
start();
};
canvas.addEventListener("pointermove", track);
canvas.addEventListener("pointerdown", track);
canvas.addEventListener("pointerleave", release);
canvas.addEventListener("pointercancel", release);
return () => {
stop();
ro.disconnect();
mo.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
canvas.removeEventListener("pointermove", track);
canvas.removeEventListener("pointerdown", track);
canvas.removeEventListener("pointerleave", release);
canvas.removeEventListener("pointercancel", release);
};
// Only the two props that change the grid's identity rebuild the loop;
// everything else is read live from `cfg`.
}, [spacing, color]);
return (
<div
data-slot="dot-field"
aria-hidden
className={cn(
"relative h-full w-full text-zinc-900 dark:text-zinc-100",
className,
)}
{...props}
>
<canvas
ref={canvasRef}
data-slot="dot-field-canvas"
className="absolute inset-0 block h-full w-full touch-pan-y"
/>
</div>
);
}lib/utils.ts
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 |
|---|---|---|---|
spacing | number | 18 | Grid pitch in CSS pixels — this is what drives the dot count. Changing it rebuilds the grid; everything else retunes the running loop in place. |
mode | "displace" | "scale" | "brighten" | "displace" | What the cursor does to nearby dots. All three route through the same smoothed slots, so they settle with identical timing. |
radius | number | 140 | Influence radius in CSS pixels. Also sets the size of the dirty rect, so it — not the grid — is what determines per-frame cost. |
strength | number | — | Peak effect at the cursor. Units depend on `mode`: pixels of push for `displace` (default 14), radius multiplier for `scale` (3), alpha multiplier for `brighten` (2.8). Defaults per mode, since 14px of push and a 14× radius are not the same request. |
attract | boolean | false | Pull dots toward the cursor instead of pushing them away. `displace` mode only. |
falloff | (t: number) => number | — | Normalised distance 0…1 → intensity 0…1. Defaults to a raised cosine, flat at both ends so neither the peak nor the radius edge creases. |
dotSize / opacity | number | 1.2 / 0.35 | Resting dot radius in CSS pixels, and resting alpha. |
color | string | — | Any canvas-valid color. Defaults to the inherited `currentColor`, re-resolved when the theme attribute flips — a canvas can't inherit it live the way SVG does. |
smoothing | number | 0.09 | Settle time constant in seconds. Applied as `1 − exp(−dt/τ)`, so the feel is frame-rate independent. |
onMeasure | (info: { dots, cols, rows, dpr }) => void | — | Fires on layout changes only, never per frame — safe to `setState` in. Useful for surfacing the live dot count. |
Dependencies
clsxtailwind-merge