"use client";
import * as React from "react";
export type PixelTrailShape = "square" | "round";
export interface PixelTrailProps extends React.ComponentPropsWithoutRef<"div"> {
/** Grid cell size, in px. */
cellSize?: number;
/** How long a lit cell takes to fade out, in ms. */
fadeDuration?: number;
/** Colour of lit cells (any CSS colour). */
color?: string;
/** Cell shape. */
shape?: PixelTrailShape;
/** Inset between neighbouring lit cells, in px. */
gap?: number;
/** Occasionally light random cells while idle. */
twinkle?: boolean;
/** Draw a faint hairline grid (squares) or dot grid (round). */
showGrid?: boolean;
}
type RGB = [number, number, number];
/* ---------------------------------- Colour --------------------------------- */
let probe: CanvasRenderingContext2D | null = null;
/** Resolves any CSS colour (hex, rgb(), oklch()…) to RGB by painting it on a 1×1 canvas. */
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 → 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];
}
const luminance = ([r, g, b]: RGB) => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
/** On light themes (dark ink), very light colours are pulled toward the ink so they stay visible. */
function legible(color: RGB, ink: RGB): RGB {
if (luminance(ink) > 0.5 || luminance(color) < 0.62) return color;
return color.map((v, i) => Math.round(v + (ink[i] - v) * 0.42)) as RGB;
}
/** Re-reads colours when the theme changes: class/data-theme on <html> or a scoped [data-theme] ancestor. */
function observeTheme(el: Element, onChange: () => void) {
const mo = new MutationObserver(onChange);
const options = { attributes: true, attributeFilter: ["class", "data-theme"] };
mo.observe(document.documentElement, options);
const scoped = el.parentElement?.closest("[data-theme]");
if (scoped && scoped !== document.documentElement) mo.observe(scoped, options);
const mq = window.matchMedia("(prefers-color-scheme: dark)");
mq.addEventListener("change", onChange);
return () => {
mo.disconnect();
mq.removeEventListener("change", onChange);
};
}
/* ----------------------------- Reduced motion ------------------------------ */
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/* -------------------------------- Component -------------------------------- */
const ALPHA_STEPS = 16;
const TWINKLE_PER_CELL_PER_SECOND = 0.004;
/**
* A grid of cells that light up under the pointer (mouse, pen or touch) and fade out, leaving a
* pixel trail. Everything is drawn on one canvas: only lit cells are tracked and painted, the
* hairline grid is cached offscreen, and the loop sleeps whenever nothing is lit.
*/
export function PixelTrail({
cellSize = 24,
fadeDuration = 900,
color = "#FF7448",
shape = "square",
gap = 2,
twinkle = true,
showGrid = true,
className = "",
children,
...props
}: PixelTrailProps) {
const reduceMotion = usePrefersReducedMotion();
const canvasRef = React.useRef<HTMLCanvasElement>(null);
React.useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return;
const cell = Math.max(4, cellSize);
const inset = Math.min(cell / 2 - 1, Math.max(0, gap)) / 2;
const fade = Math.max(50, fadeDuration);
const gridLayer = document.createElement("canvas");
let width = 0;
let height = 0;
let dpr = 1;
let cols = 0;
let rows = 0;
let ox = 0;
let oy = 0;
let raf = 0;
let last = 0;
let inView = false;
let fills: string[] = [];
// Lit cells: brightness per cell plus a compact list of the indices currently above zero.
let level = new Float32Array(0);
let active: number[] = [];
// Last pointer position in canvas space; null after the pointer leaves or lifts.
let prev: { x: number; y: number } | null = null;
const readTheme = () => {
const ink = resolveColor(getComputedStyle(canvas).color, [24, 24, 27]);
const c = legible(resolveColor(color, [255, 116, 72]), ink);
fills = Array.from({ length: ALPHA_STEPS + 1 }, (_, i) => `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${(i / ALPHA_STEPS).toFixed(3)})`);
paintGrid(ink);
};
/** The idle grid is static, so it is rendered once into an offscreen canvas. */
const paintGrid = (ink: RGB) => {
gridLayer.width = canvas.width;
gridLayer.height = canvas.height;
const g = gridLayer.getContext("2d");
if (!g || !showGrid) return;
g.setTransform(dpr, 0, 0, dpr, 0, 0);
if (shape === "square") {
g.strokeStyle = `rgba(${ink[0]}, ${ink[1]}, ${ink[2]}, 0.07)`;
g.lineWidth = 1 / dpr;
g.beginPath();
for (let c = 0; c <= cols; c++) {
const x = Math.round((ox + c * cell) * dpr) / dpr + 0.5 / dpr;
g.moveTo(x, 0);
g.lineTo(x, height);
}
for (let r = 0; r <= rows; r++) {
const y = Math.round((oy + r * cell) * dpr) / dpr + 0.5 / dpr;
g.moveTo(0, y);
g.lineTo(width, y);
}
g.stroke();
} else {
g.fillStyle = `rgba(${ink[0]}, ${ink[1]}, ${ink[2]}, 0.16)`;
g.beginPath();
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const x = ox + (c + 0.5) * cell;
const y = oy + (r + 0.5) * cell;
g.moveTo(x + 1, y);
g.arc(x, y, 1, 0, Math.PI * 2);
}
}
g.fill();
}
};
const draw = () => {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (showGrid) ctx.drawImage(gridLayer, 0, 0);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const size = cell - inset * 2;
for (const idx of active) {
const v = level[idx];
// Ease-out fade: bright for most of the lifetime, quick tail at the end.
const alpha = Math.round(Math.min(1, v * (2 - v)) * 0.92 * ALPHA_STEPS);
if (alpha <= 0) continue;
const x = ox + (idx % cols) * cell + inset;
const y = oy + Math.floor(idx / cols) * cell + inset;
ctx.fillStyle = fills[alpha];
if (shape === "round") {
ctx.beginPath();
ctx.arc(x + size / 2, y + size / 2, size / 2, 0, Math.PI * 2);
ctx.fill();
} else ctx.fillRect(x, y, size, size);
}
};
const light = (idx: number, value: number) => {
if (level[idx] <= 0) active.push(idx);
if (value > level[idx]) level[idx] = value;
};
const cellAt = (x: number, y: number) => {
const c = Math.floor((x - ox) / cell);
const r = Math.floor((y - oy) / cell);
return c < 0 || r < 0 || c >= cols || r >= rows ? -1 : r * cols + c;
};
/** Lights every cell along the segment from the previous position so fast moves leave no gaps. */
const trace = (x: number, y: number) => {
if (reduceMotion) {
// Calm alternative: only the cell under the pointer is lit, with no lingering trail.
for (const idx of active) level[idx] = 0;
active = [];
const idx = cellAt(x, y);
if (idx >= 0) light(idx, 0.75);
draw();
return;
}
const from = prev ?? { x, y };
const steps = Math.max(1, Math.ceil(Math.hypot(x - from.x, y - from.y) / (cell * 0.5)));
for (let s = 1; s <= steps; s++) {
const idx = cellAt(from.x + ((x - from.x) * s) / steps, from.y + ((y - from.y) * s) / steps);
if (idx >= 0) light(idx, 1);
}
prev = { x, y };
schedule();
};
const twinkling = () => twinkle && !reduceMotion;
const frame = (now: number) => {
raf = 0;
const dt = Math.min(64, last ? now - last : 16.7);
last = now;
if (twinkling()) {
// Poisson-ish sprinkling proportional to the number of cells.
let expected = cols * rows * TWINKLE_PER_CELL_PER_SECOND * (dt / 1000);
while (expected > 0) {
if (Math.random() < expected) light(Math.floor(Math.random() * cols * rows), 0.3 + Math.random() * 0.3);
expected -= 1;
}
}
const decay = dt / fade;
for (let i = active.length - 1; i >= 0; i--) {
const idx = active[i];
level[idx] -= decay;
if (level[idx] <= 0) {
level[idx] = 0;
// Swap-remove keeps the active list compact.
active[i] = active[active.length - 1];
active.pop();
}
}
draw();
if (active.length > 0 || twinkling()) schedule();
else last = 0;
};
const schedule = () => {
if (!raf && inView && !reduceMotion && !document.hidden) raf = requestAnimationFrame(frame);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
last = 0;
};
const resize = () => {
const rect = canvas.getBoundingClientRect();
width = rect.width;
height = rect.height;
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.max(1, Math.round(width * dpr));
canvas.height = Math.max(1, Math.round(height * dpr));
cols = Math.max(1, Math.ceil(width / cell));
rows = Math.max(1, Math.ceil(height / cell));
// Centre the grid so partial cells are split evenly between opposite edges.
ox = (width - cols * cell) / 2;
oy = (height - rows * cell) / 2;
level = new Float32Array(cols * rows);
active = [];
prev = null;
readTheme();
draw();
schedule();
};
const onThemeChange = () => {
readTheme();
draw();
};
const toLocal = (clientX: number, clientY: number) => {
const rect = canvas.getBoundingClientRect();
const x = clientX - rect.left;
const y = clientY - rect.top;
return x >= 0 && y >= 0 && x <= rect.width && y <= rect.height ? { x, y } : null;
};
const handle = (clientX: number, clientY: number) => {
const p = toLocal(clientX, clientY);
if (p) trace(p.x, p.y);
else release();
};
const release = () => {
prev = null;
if (reduceMotion && active.length) {
for (const idx of active) level[idx] = 0;
active = [];
draw();
}
};
// Touch is handled by passive touch listeners (they keep firing while the page scrolls);
// mouse and pen use pointer events. Nothing calls preventDefault, so scrolling is never blocked.
const onPointerMove = (e: PointerEvent) => {
if (e.pointerType !== "touch") handle(e.clientX, e.clientY);
};
const onTouch = (e: TouchEvent) => {
const t = e.touches[0];
if (t) handle(t.clientX, t.clientY);
};
const onVisibility = () => (document.hidden ? stop() : schedule());
const listeners = (on: boolean) => {
if (on) {
window.addEventListener("pointermove", onPointerMove, { passive: true });
window.addEventListener("touchstart", onTouch, { passive: true });
window.addEventListener("touchmove", onTouch, { passive: true });
window.addEventListener("touchend", release, { passive: true });
window.addEventListener("touchcancel", release, { passive: true });
document.documentElement.addEventListener("pointerleave", release);
} else {
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("touchstart", onTouch);
window.removeEventListener("touchmove", onTouch);
window.removeEventListener("touchend", release);
window.removeEventListener("touchcancel", release);
document.documentElement.removeEventListener("pointerleave", release);
}
};
const io = new IntersectionObserver(([entry]) => {
inView = entry.isIntersecting;
listeners(inView);
if (inView) schedule();
else {
release();
stop();
}
});
resize();
const ro = new ResizeObserver(resize);
ro.observe(canvas);
io.observe(canvas);
const unobserveTheme = observeTheme(canvas, onThemeChange);
document.addEventListener("visibilitychange", onVisibility);
return () => {
stop();
ro.disconnect();
io.disconnect();
unobserveTheme();
listeners(false);
document.removeEventListener("visibilitychange", onVisibility);
};
}, [cellSize, fadeDuration, color, shape, gap, twinkle, showGrid, reduceMotion]);
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>
);
}