"use client";
import * as React from "react";
export type DotMatrixColorMode = "mono" | "accent" | "gradient";
export interface DotMatrixFieldProps extends React.ComponentPropsWithoutRef<"div"> {
/** Distance between dot centres, in px. Lower = denser. */
spacing?: number;
/** Resting dot radius, in px. */
dotSize?: number;
/** Radius of the pointer's influence, in px. */
influence?: number;
/** mono: text colour only · accent: lit dots shift to the accent · gradient: accent → secondary across the field. */
colorMode?: DotMatrixColorMode;
/** Accent colour (any CSS colour). */
accent?: string;
/** Second colour for gradient mode (any CSS colour). */
secondary?: string;
/** Subtle idle wave travelling across the grid. */
animate?: boolean;
}
type RGB = [number, number, number];
const RAMP_STEPS = 24;
let probe: CanvasRenderingContext2D | null = null;
/**
* Resolves any CSS colour (hex, rgb(), oklch(), named…) to RGB by painting it on a 1×1 canvas.
* Tailwind v4 palettes are oklch, so string parsing alone is not enough.
*/
function resolveColor(input: string, fallback: RGB): RGB {
probe ??= document.createElement("canvas").getContext("2d", { willReadFrequently: true });
if (!probe) return fallback;
probe.clearRect(0, 0, 1, 1);
probe.fillStyle = "rgba(0, 0, 0, 0)";
probe.fillStyle = input; // invalid colours are ignored, leaving transparent → fallback
probe.fillRect(0, 0, 1, 1);
const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;
return a === 0 ? fallback : [r, g, b];
}
/** Precomputed colour strings between two colours, so the draw loop never builds strings. */
function ramp(from: RGB, to: RGB): string[] {
return Array.from({ length: RAMP_STEPS }, (_, i) => {
const t = i / (RAMP_STEPS - 1);
const c = from.map((v, k) => Math.round(v + (to[k] - v) * t));
return `rgb(${c[0]}, ${c[1]}, ${c[2]})`;
});
}
const smoothstep = (t: number) => t * t * (3 - 2 * t);
/**
* An interactive halftone dot grid drawn on a single canvas. Dots near a mouse pointer grow
* and brighten; touch devices get a static (or gently waving) field. The render loop only runs
* while something is changing, and never while offscreen or in a hidden tab.
*/
export function DotMatrixField({
spacing = 18,
dotSize = 1.4,
influence = 140,
colorMode = "accent",
accent = "#FF7448",
secondary = "#B6F3D1",
animate = true,
className = "",
children,
...props
}: DotMatrixFieldProps) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
React.useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return;
const reducedMq = window.matchMedia("(prefers-reduced-motion: reduce)");
const darkMq = window.matchMedia("(prefers-color-scheme: dark)");
const accentRgb = resolveColor(accent, [255, 116, 72]);
const secondaryRgb = resolveColor(secondary, [182, 243, 209]);
const gap = Math.max(4, spacing);
let width = 0;
let height = 0;
let dpr = 1;
let inView = false;
let raf = 0;
let last = 0;
let fgRamp: string[] = [];
let columnColors: string[] = [];
let accentRamp: string[] = [];
// Pointer state, in canvas-local CSS px. `strength` eases in/out as the pointer enters/leaves.
const pointer = { clientX: 0, clientY: 0, x: 0, y: 0, tx: 0, ty: 0, strength: 0, target: 0, fresh: true };
/** Theme-aware base colour: the canvas inherits `color` from Tailwind text classes. */
const readTheme = () => {
const fg = resolveColor(getComputedStyle(canvas).color, [24, 24, 27]);
fgRamp = ramp(fg, fg);
accentRamp = ramp(fg, accentRgb);
const cols = Math.floor(width / gap) + 1;
const gradient = ramp(accentRgb, secondaryRgb);
columnColors = Array.from({ length: cols }, (_, c) => gradient[Math.round((c / Math.max(1, cols - 1)) * (RAMP_STEPS - 1))]);
};
const waving = () => animate && !reducedMq.matches;
const draw = (time: number) => {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
const cols = Math.floor(width / gap) + 1;
const rows = Math.floor(height / gap) + 1;
const ox = (width - (cols - 1) * gap) / 2;
const oy = (height - (rows - 1) * gap) / 2;
const wave = waving();
const s = pointer.strength;
const reach = Math.max(1, influence);
for (let r = 0; r < rows; r++) {
const y = oy + r * gap;
for (let c = 0; c < cols; c++) {
const x = ox + c * gap;
// Idle wave: a slow diagonal swell of size and opacity.
const w = wave ? (Math.sin(x * 0.016 + y * 0.011 - time * 0.0011) + 1) / 2 : 0.5;
let alpha = 0.16 + w * 0.16;
let radius = dotSize * (0.85 + w * 0.3);
let lit = 0;
if (s > 0.001) {
const dx = x - pointer.x;
const dy = y - pointer.y;
const d2 = dx * dx + dy * dy;
if (d2 < reach * reach) lit = smoothstep(1 - Math.sqrt(d2) / reach) * s;
}
alpha += (1 - alpha) * lit;
radius += dotSize * 1.9 * lit;
let fill: string;
if (colorMode === "gradient") fill = columnColors[c] ?? accentRamp[RAMP_STEPS - 1];
else if (colorMode === "accent") fill = accentRamp[Math.round(lit * (RAMP_STEPS - 1))];
else fill = fgRamp[0];
ctx.globalAlpha = colorMode === "gradient" ? Math.min(1, alpha * 1.6) : alpha;
ctx.fillStyle = fill;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
}
}
ctx.globalAlpha = 1;
};
const frame = (time: number) => {
raf = 0;
const dt = Math.min(64, last ? time - last : 16.7);
last = time;
// Resolve pointer position against the current rect (handles scroll without listeners).
if (pointer.target > 0) {
const rect = canvas.getBoundingClientRect();
pointer.tx = pointer.clientX - rect.left;
pointer.ty = pointer.clientY - rect.top;
if (pointer.fresh) {
pointer.x = pointer.tx;
pointer.y = pointer.ty;
pointer.fresh = false;
}
}
// Frame-rate independent smoothing.
const k = 1 - Math.pow(1 - 0.22, dt / 16.7);
pointer.x += (pointer.tx - pointer.x) * k;
pointer.y += (pointer.ty - pointer.y) * k;
pointer.strength += (pointer.target - pointer.strength) * (1 - Math.pow(1 - 0.12, dt / 16.7));
draw(time);
const settling =
Math.abs(pointer.target - pointer.strength) > 0.002 ||
(pointer.strength > 0.002 && Math.hypot(pointer.tx - pointer.x, pointer.ty - pointer.y) > 0.3);
if (waving() || settling) schedule();
else last = 0;
};
const schedule = () => {
if (!raf && inView && !document.hidden) raf = requestAnimationFrame(frame);
};
const resize = () => {
const rect = canvas.getBoundingClientRect();
width = rect.width;
height = rect.height;
dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.max(1, Math.round(width * dpr));
canvas.height = Math.max(1, Math.round(height * dpr));
readTheme();
draw(performance.now());
schedule();
};
const onPointerMove = (e: PointerEvent) => {
if (e.pointerType !== "mouse") return;
const rect = canvas.getBoundingClientRect();
const inside = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
const previous = pointer.target;
pointer.clientX = e.clientX;
pointer.clientY = e.clientY;
if (inside && previous === 0) pointer.fresh = pointer.strength < 0.01;
pointer.target = inside ? 1 : 0;
// Moves elsewhere on the page never wake the loop.
if (inside || previous !== pointer.target) schedule();
};
const onPointerLeave = () => {
pointer.target = 0;
schedule();
};
const onThemeChange = () => {
readTheme();
draw(performance.now());
schedule();
};
const onVisibility = () => {
if (document.hidden) {
cancelAnimationFrame(raf);
raf = 0;
last = 0;
} else schedule();
};
// Pointer tracking is attached to the window only while the field is visible, so the
// background works whether content is layered inside it or beside it.
const io = new IntersectionObserver(([entry]) => {
inView = entry.isIntersecting;
if (inView) {
window.addEventListener("pointermove", onPointerMove, { passive: true });
document.documentElement.addEventListener("pointerleave", onPointerLeave);
schedule();
} else {
window.removeEventListener("pointermove", onPointerMove);
document.documentElement.removeEventListener("pointerleave", onPointerLeave);
cancelAnimationFrame(raf);
raf = 0;
last = 0;
}
});
const ro = new ResizeObserver(resize);
// Class-based themes (e.g. `.dark` on <html>) change the inherited colour without a resize.
const mo = new MutationObserver(onThemeChange);
resize();
ro.observe(canvas);
io.observe(canvas);
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-theme", "style"] });
darkMq.addEventListener("change", onThemeChange);
reducedMq.addEventListener("change", onThemeChange);
document.addEventListener("visibilitychange", onVisibility);
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
io.disconnect();
mo.disconnect();
darkMq.removeEventListener("change", onThemeChange);
reducedMq.removeEventListener("change", onThemeChange);
document.removeEventListener("visibilitychange", onVisibility);
window.removeEventListener("pointermove", onPointerMove);
document.documentElement.removeEventListener("pointerleave", onPointerLeave);
};
}, [spacing, dotSize, influence, colorMode, accent, secondary, animate]);
return (
<div className={`absolute inset-0 overflow-hidden ${className}`} {...props}>
<canvas
ref={canvasRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0 block h-full w-full text-zinc-900 dark:text-zinc-100"
/>
{children != null && <div className="relative h-full w-full">{children}</div>}
</div>
);
}