Latticework
An interactive dot grid: rivet-head dots warm violet → ember red → amber as the cursor approaches, with a configurable falloff curve. Freezes to a static, gently warmed frame under prefers-reduced-motion.
canvasfree
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
import { useVisibilityPause } from "@/registry/default/hooks/use-visibility-pause/use-visibility-pause";
export type LatticeworkFalloff = "linear" | "exponential" | "gaussian";
export interface LatticeworkProps extends React.ComponentPropsWithoutRef<"div"> {
/** Spacing between dots in px. Default 34. */
spacing?: number;
/** Resting dot radius in px. Default 1.4. */
dotRadius?: number;
/** Proximity radius in px within which dots warm up. Default 180. */
radius?: number;
/** Shape of the heat falloff curve across the proximity radius. Default "gaussian". */
falloff?: LatticeworkFalloff;
/** Cold → mid → hot color ramp. Defaults to violet → ember red → amber. */
colors?: [string, string, string];
/** How quickly dots ease toward their target heat, 0–1 per frame at 60fps. Default 0.12. */
responsiveness?: number;
/** devicePixelRatio ceiling. Default 2. */
dpr?: number;
/**
* Pointer-down "hammer-strike" strength: an expanding ring of displacement
* and temporary heat radiates from the strike point. 0 disables strikes.
* On coarse pointers a tap lands one strike. Default 1.
*/
strikeStrength?: number;
/** Freeze the field — pointer tracking stops and the current frame holds. */
paused?: boolean;
}
interface Strike {
x: number;
y: number;
born: number;
}
/** Lifetime of one strike ring, seconds. */
const STRIKE_LIFE = 1.3;
interface Internals {
ctx: CanvasRenderingContext2D;
cols: number;
rows: number;
heat: Float32Array;
pointer: [number, number] | null;
strikes: Strike[];
time: number;
width: number;
height: number;
spacing: number;
dotRadius: number;
radius: number;
falloff: LatticeworkFalloff;
cold: [number, number, number];
mid: [number, number, number];
hot: [number, number, number];
responsiveness: number;
strikeStrength: number;
}
function hexToRgb(hex: string): [number, number, number] {
let h = hex.replace("#", "").trim();
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
const int = parseInt(h, 16);
if (Number.isNaN(int) || h.length !== 6) return [255, 107, 53];
return [(int >> 16) & 255, (int >> 8) & 255, int & 255];
}
function falloffCurve(t: number, kind: LatticeworkFalloff): number {
// t is normalized distance, 0 = on top of the dot, 1 = at the radius edge.
const c = Math.min(Math.max(1 - t, 0), 1);
switch (kind) {
case "linear":
return c;
case "exponential":
return c * c;
case "gaussian":
default:
return Math.exp(-(t * t) * 4);
}
}
function mixRgb(a: [number, number, number], b: [number, number, number], t: number): [number, number, number] {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
function rampColor(
heat: number,
cold: [number, number, number],
mid: [number, number, number],
hot: [number, number, number]
): [number, number, number] {
if (heat <= 0.5) return mixRgb(cold, mid, heat * 2);
return mixRgb(mid, hot, (heat - 0.5) * 2);
}
function layoutGrid(it: Internals, width: number, height: number, spacing: number) {
it.width = width;
it.height = height;
it.spacing = spacing;
it.cols = Math.max(1, Math.floor(width / spacing) + 1);
it.rows = Math.max(1, Math.floor(height / spacing) + 1);
it.heat = new Float32Array(it.cols * it.rows);
}
/** Baseline diagonal warmth so the resting field never reads as a black void. */
function restingGradient(rx: number, ry: number, cols: number, rows: number): number {
return Math.max(0, 1 - (Math.abs(rx / cols - 0.5) + Math.abs(ry / rows - 0.5)) * 1.1);
}
function draw(it: Internals, staticFrame: boolean, dt: number) {
const { ctx, cols, rows, spacing, dotRadius, radius, pointer, strikes } = it;
ctx.clearRect(0, 0, it.width, it.height);
// Prune spent strike rings before the dot pass.
if (strikes.length > 0) {
for (let i = strikes.length - 1; i >= 0; i--) {
if (it.time - strikes[i].born > STRIKE_LIFE) strikes.splice(i, 1);
}
}
const ringSpeed = radius * 3.2; // px/s — the hammer wave front
const ringWidth = Math.max(radius * 0.32, spacing * 1.4);
for (let ry = 0; ry < rows; ry++) {
for (let rx = 0; rx < cols; rx++) {
const idx = ry * cols + rx;
const x = rx * spacing;
const y = ry * spacing;
let target = 0;
if (staticFrame) {
// Aesthetic resting frame: a gentle diagonal warmth gradient across
// the field instead of a flat, lifeless grid of identical dots.
target = restingGradient(rx, ry, cols, rows) * 0.35;
} else {
// Live idle floor: a fainter version of the same gradient, so the
// stage never reads empty before the pointer arrives.
target = restingGradient(rx, ry, cols, rows) * 0.16;
if (pointer) {
const dx = x - pointer[0];
const dy = y - pointer[1];
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist <= radius) {
target = Math.max(target, falloffCurve(dist / radius, it.falloff));
}
}
}
const prev = it.heat[idx];
const ease = staticFrame ? 1 : 1 - Math.pow(1 - it.responsiveness, Math.max(dt, 1) / 16.7);
let next = prev + (target - prev) * ease;
// Hammer-strike rings: an expanding wave front displaces each dot
// radially as it passes and leaves temporary heat behind (which then
// cools through the same easing as pointer warmth).
let ox = 0;
let oy = 0;
if (!staticFrame && strikes.length > 0 && it.strikeStrength > 0) {
for (let si = 0; si < strikes.length; si++) {
const s = strikes[si];
const age = it.time - s.born;
if (age < 0) continue;
const decay = Math.max(1 - age / STRIKE_LIFE, 0);
const ringR = age * ringSpeed;
const dx = x - s.x;
const dy = y - s.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const rel = (dist - ringR) / ringWidth;
const band = Math.exp(-rel * rel);
if (band < 0.01) continue;
const amt = band * decay * it.strikeStrength;
next = Math.min(next + amt * 0.85, 1);
ox += (dx / dist) * amt * spacing * 0.45;
oy += (dy / dist) * amt * spacing * 0.45;
}
}
it.heat[idx] = next;
const [r, g, b] = rampColor(next, it.cold, it.mid, it.hot);
const radiusPx = dotRadius + next * dotRadius * 1.8;
const alpha = 0.5 + next * 0.5;
if (next > 0.03) {
ctx.save();
ctx.shadowColor = `rgba(${r}, ${g}, ${b}, ${Math.min(next, 1)})`;
ctx.shadowBlur = 10 * next;
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`;
ctx.beginPath();
ctx.arc(x + ox, y + oy, radiusPx, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
} else {
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, 0.5)`;
ctx.beginPath();
ctx.arc(x + ox, y + oy, dotRadius, 0, Math.PI * 2);
ctx.fill();
}
}
}
}
/**
* Latticework — a dot grid, rivet-plate flat at rest, that warms toward
* amber (through ember red and deep violet) as the cursor approaches each
* dot, with a configurable falloff curve for how sharply heat drops off with
* distance. Pointer-down hammer-strikes the field: an expanding ring of
* displacement and temporary heat radiates from the strike point (several
* strikes can coexist). Under reduced motion / paused it renders one static,
* gently warmed diagonal frame instead of a flat, lifeless grid — and takes
* no strikes.
*/
export function Latticework({
spacing = 34,
dotRadius = 1.4,
radius = 180,
falloff = "gaussian",
colors = ["#5a189a", "#c1121f", "#ff6b35"],
responsiveness = 0.12,
dpr = 2,
strikeStrength = 1,
paused = false,
className,
...props
}: LatticeworkProps) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const internalsRef = React.useRef<Internals | null>(null);
const reducedMotion = useReducedMotion();
const staticFrame = paused || reducedMotion;
const staticFrameRef = React.useRef(staticFrame);
staticFrameRef.current = staticFrame;
const lastElapsedRef = React.useRef(0);
const tick = React.useCallback(
(elapsed: number) => {
const it = internalsRef.current;
if (!it) return;
const dt = (elapsed - lastElapsedRef.current) * 1000;
lastElapsedRef.current = elapsed;
it.time = elapsed;
draw(it, false, dt);
},
[]
);
const containerRef = useVisibilityPause<HTMLDivElement>(tick, { paused: staticFrame });
React.useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const effectiveDpr = Math.min(typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1, dpr);
const it: Internals = {
ctx,
cols: 0,
rows: 0,
heat: new Float32Array(0),
pointer: null,
strikes: [],
time: lastElapsedRef.current,
width: 0,
height: 0,
spacing: spacing * effectiveDpr,
dotRadius: dotRadius * effectiveDpr,
radius: radius * effectiveDpr,
falloff,
cold: hexToRgb(colors[0]),
mid: hexToRgb(colors[1]),
hot: hexToRgb(colors[2]),
responsiveness,
strikeStrength: Math.max(strikeStrength, 0),
};
internalsRef.current = it;
const resize = () => {
const w = Math.max(container.clientWidth, 1);
const h = Math.max(container.clientHeight, 1);
canvas.width = Math.round(w * effectiveDpr);
canvas.height = Math.round(h * effectiveDpr);
canvas.style.width = "100%";
canvas.style.height = "100%";
layoutGrid(it, canvas.width, canvas.height, spacing * effectiveDpr);
draw(it, paused || reducedMotion, 0);
};
const ro = new ResizeObserver(resize);
ro.observe(container);
resize();
const onPointerMove = (e: PointerEvent) => {
const r = container.getBoundingClientRect();
it.pointer = [(e.clientX - r.left) * effectiveDpr, (e.clientY - r.top) * effectiveDpr];
};
const onPointerLeave = () => {
it.pointer = null;
};
const onPointerDown = (e: PointerEvent) => {
// Hammer-strike: one expanding ring per pointer-down (a tap on coarse
// pointers counts). No rings under reduced motion / paused.
if (staticFrameRef.current || it.strikeStrength <= 0) return;
const r = container.getBoundingClientRect();
it.strikes.push({
x: (e.clientX - r.left) * effectiveDpr,
y: (e.clientY - r.top) * effectiveDpr,
born: it.time,
});
if (it.strikes.length > 6) it.strikes.shift();
};
container.addEventListener("pointermove", onPointerMove);
container.addEventListener("pointerleave", onPointerLeave);
container.addEventListener("pointerdown", onPointerDown);
return () => {
ro.disconnect();
container.removeEventListener("pointermove", onPointerMove);
container.removeEventListener("pointerleave", onPointerLeave);
container.removeEventListener("pointerdown", onPointerDown);
internalsRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dpr, spacing, dotRadius, radius, falloff, colors.join(","), responsiveness, strikeStrength]);
// Re-render the frozen frame whenever paused/reduced-motion state changes.
React.useEffect(() => {
const it = internalsRef.current;
if (!it) return;
it.pointer = null;
it.strikes.length = 0;
draw(it, staticFrame, 0);
}, [staticFrame]);
return (
<div
ref={containerRef}
aria-hidden
data-crucible="latticework"
className={cn("absolute inset-0 -z-10 overflow-hidden", className)}
{...props}
>
<canvas ref={canvasRef} className="block h-full w-full" />
</div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/latticeworkHonors prefers-reduced-motion with a designed static fallback, pauses offscreen and on hidden tabs, and passes className through.