"use client";
import * as React from "react";
export interface ElectricArcAnchor {
/** Horizontal position as a fraction of the width, 0–1. */
x: number;
/** Vertical position as a fraction of the height, 0–1. */
y: number;
}
export interface ElectricArcProps extends React.ComponentPropsWithoutRef<"div"> {
/** Brightness, thickness and how often arcs strike, 0.1–1. */
intensity?: number;
/** How many forks split off each bolt, 0–1. */
branching?: number;
/** Arc colour (any CSS colour). Deepened automatically on light themes. */
color?: string;
/** Number of anchors in the default layout. Ignored when `anchors` is given. */
nodes?: number;
/** Custom anchor positions as fractions of the container; overrides `nodes`. */
anchors?: ElectricArcAnchor[];
/** Distance in px within which the nearest anchor arcs to the pointer. 0 disables it. */
reach?: number;
/** Draw the anchor terminals. */
showAnchors?: 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;
const mix = (a: RGB, b: RGB, t: number) => a.map((v, i) => Math.round(v + (b[i] - v) * t)) as RGB;
const rgba = ([r, g, b]: RGB, a: number) => `rgba(${r}, ${g}, ${b}, ${a.toFixed(3)})`;
/** 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);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/* ---------------------------------- Bolts ---------------------------------- */
/** mulberry32: a tiny seeded PRNG, so the reduced-motion still is the same on every visit. */
function seeded(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/**
* Recursive midpoint displacement: every pass splits each segment and pushes its midpoint
* sideways by a random amount, halving the amount each pass. Returns flat [x0, y0, x1, y1, …].
*/
function displace(x0: number, y0: number, x1: number, y1: number, rough: number, levels: number, rnd: () => number) {
let pts = [x0, y0, x1, y1];
const len = Math.hypot(x1 - x0, y1 - y0) || 1;
const nx = -(y1 - y0) / len;
const ny = (x1 - x0) / len;
let amp = len * rough;
for (let level = 0; level < levels; level++) {
const next: number[] = [];
for (let i = 0; i < pts.length - 2; i += 2) {
const o = (rnd() * 2 - 1) * amp;
next.push(pts[i], pts[i + 1], (pts[i] + pts[i + 2]) / 2 + nx * o, (pts[i + 1] + pts[i + 3]) / 2 + ny * o);
}
next.push(pts[pts.length - 2], pts[pts.length - 1]);
pts = next;
amp *= 0.5;
}
return pts;
}
/**
* A bolt lives in its arc's local frame — (0,0) is one end, (1,0) the other — so it stays
* attached when the container resizes or the pointer end moves between re-routes.
*/
interface Bolt {
main: number[];
forks: number[][];
}
function makeBolt(rnd: () => number, lengthPx: number, branching: number): Bolt {
const levels = lengthPx > 520 ? 7 : lengthPx > 160 ? 6 : 5;
const main = displace(0, 0, 1, 0, 0.17, levels, rnd);
const forks: number[][] = [];
const count = Math.floor(branching * (1.2 + rnd() * 3.4));
const segments = main.length / 2 - 1;
for (let k = 0; k < count; k++) {
const i = 2 * Math.floor((0.12 + rnd() * 0.7) * segments);
const sx = main[i];
const sy = main[i + 1];
// Forks leave at 20–55° to the local direction of the bolt, and shrink toward the far end.
const dir = Math.atan2(main[i + 3] - sy, main[i + 2] - sx);
const angle = dir + (rnd() < 0.5 ? -1 : 1) * (0.35 + rnd() * 0.6);
const length = (0.08 + rnd() * 0.24) * (1 - sx * 0.5);
forks.push(displace(sx, sy, sx + Math.cos(angle) * length, sy + Math.sin(angle) * length, 0.24, levels - 2, rnd));
}
return { main, forks };
}
/* --------------------------------- Layout ---------------------------------- */
/** Default anchors: evenly spaced across, zig-zagging vertically by the golden ratio. */
function defaultAnchors(n: number): ElectricArcAnchor[] {
return Array.from({ length: n }, (_, i) => ({
x: n === 1 ? 0.5 : 0.1 + (0.8 * i) / (n - 1),
y: 0.22 + 0.56 * ((i * 0.618034 + 0.31) % 1),
}));
}
/** Minimum spanning tree (Prim) over the anchors, measured in a 16:10 frame: every anchor gets wired. */
function spanningEdges(points: ElectricArcAnchor[]): [number, number][] {
const n = points.length;
if (n < 2) return [];
const dist = (a: number, b: number) => Math.hypot((points[a].x - points[b].x) * 1.6, points[a].y - points[b].y);
const inTree = new Set([0]);
const edges: [number, number][] = [];
while (inTree.size < n) {
let best: [number, number] | null = null;
let bestD = Infinity;
for (const a of inTree) {
for (let b = 0; b < n; b++) {
if (inTree.has(b)) continue;
const d = dist(a, b);
if (d < bestD) {
bestD = d;
best = [a, b];
}
}
}
if (!best) break;
inTree.add(best[1]);
edges.push(best);
}
return edges;
}
/* -------------------------------- Component -------------------------------- */
/**
* WCAG 2.3.1 guard: a "strike" (an arc switching on with a brightness spike) may begin at most
* once per 340ms across the whole canvas — under 3 per second. Re-routes in between only move
* thin lines, and the background itself never flashes.
*/
const MIN_STRIKE_GAP = 340;
interface Arc {
a: number;
/** Anchor index, or -1 for the pointer. */
b: number;
bolt: Bolt;
on: boolean;
/** ms left in the current on/off phase. */
timer: number;
/** ms until the path is regenerated. */
reroute: number;
/** Strike brightness, decays from 1 to 0. */
flash: number;
/** Small per-re-route brightness variation (0.88–1). */
flicker: number;
}
/**
* Electric arcs crackling between fixed anchors. Each bolt is built by recursive midpoint
* displacement with forks, re-routes many times a second and glows through additive stroke
* layers (no blur filters). The nearest anchor arcs to a nearby pointer. Strikes are rate-limited
* to under 3 per second, and reduced motion draws a still.
*/
export function ElectricArc({
intensity = 0.6,
branching = 0.35,
color = "#8DB8FF",
nodes = 5,
anchors,
reach = 200,
showAnchors = true,
className = "",
children,
...props
}: ElectricArcProps) {
const reduceMotion = usePrefersReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const anchorKey = anchors?.map((a) => `${a.x},${a.y}`).join(";") ?? "";
React.useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!root || !canvas || !ctx) return;
const level = Math.min(1, Math.max(0.1, intensity));
const forkiness = Math.min(1, Math.max(0, branching));
const layout: ElectricArcAnchor[] = anchorKey
? anchorKey.split(";").map((s) => {
const [x, y] = s.split(",").map(Number);
return { x: Math.min(1, Math.max(0, x || 0)), y: Math.min(1, Math.max(0, y || 0)) };
})
: defaultAnchors(Math.max(2, Math.min(12, Math.round(nodes))));
const edges = spanningEdges(layout);
const rnd = reduceMotion ? seeded(7 + layout.length * 31) : Math.random;
let width = 0;
let height = 0;
let dpr = 1;
let raf = 0;
let last = 0;
let time = 0;
let nextStrikeAt = 0;
let inView = false;
let dark = true;
let glow: RGB = [141, 184, 255];
let core: RGB = [235, 243, 255];
const points = layout.map(() => ({ x: 0, y: 0 }));
const ptr = { x: 0, y: 0, inside: false, touch: false };
const live = new Set<number>(); // anchors with an arc currently on
const lengthOf = (arc: Arc) => {
const a = points[arc.a];
const b = arc.b < 0 ? ptr : points[arc.b];
return Math.hypot(b.x - a.x, b.y - a.y);
};
const newArc = (a: number, b: number): Arc => ({
a,
b,
bolt: { main: [], forks: [] },
on: reduceMotion,
// Stagger the first strikes so the field wakes up gradually.
timer: reduceMotion ? Infinity : 120 + rnd() * 1400,
reroute: 0,
flash: 0,
flicker: 1,
});
const arcs = edges.map(([a, b]) => newArc(a, b));
const pointerArc = newArc(0, -1);
pointerArc.on = false;
pointerArc.timer = 0;
const readTheme = () => {
const ink = resolveColor(getComputedStyle(canvas).color, [24, 24, 27]);
dark = luminance(ink) > 0.5;
const base = resolveColor(color, [141, 184, 255]);
if (dark) {
glow = base;
core = mix(base, [255, 255, 255], 0.78);
} else {
// On light grounds a pale arc vanishes: deepen it (more saturated, darker, same hue).
const mean = (base[0] + base[1] + base[2]) / 3;
const rich = base.map((v) => Math.min(255, Math.max(0, mean + (v - mean) * 1.8))) as RGB;
const scale = luminance(rich) > 0.35 ? 0.35 / luminance(rich) : 1;
glow = rich.map((v) => Math.round(v * scale)) as RGB;
core = mix(glow, [12, 14, 22], 0.3);
}
};
const reroute = (arc: Arc) => {
arc.bolt = makeBolt(rnd, lengthOf(arc), forkiness);
arc.flicker = 0.88 + rnd() * 0.12;
arc.reroute = 45 + rnd() * (95 - 45 * level);
};
/** Try to begin a strike; returns false while the global flash budget is spent. */
const strike = (arc: Arc) => {
if (time < nextStrikeAt) return false;
nextStrikeAt = time + MIN_STRIKE_GAP;
arc.on = true;
arc.flash = 1;
reroute(arc);
return true;
};
const update = (dt: number) => {
// The pointer arc goes first, so it gets the next strike slot instead of queueing behind
// the anchor arcs. It runs from the nearest anchor while the pointer is within reach of it.
let nearest = -1;
if (ptr.inside && reach > 0) {
let best = reach;
points.forEach((p, i) => {
const d = Math.hypot(p.x - ptr.x, p.y - ptr.y);
if (d < best && d > 14) {
best = d;
nearest = i;
}
});
}
if (nearest < 0) pointerArc.on = false;
else if (!pointerArc.on) {
pointerArc.a = nearest;
strike(pointerArc);
} else {
pointerArc.a = nearest;
pointerArc.reroute -= dt;
if (pointerArc.reroute <= 0) reroute(pointerArc);
}
pointerArc.flash *= Math.exp(-dt / 110);
for (const arc of arcs) {
arc.timer -= dt;
if (arc.on) {
arc.reroute -= dt;
if (arc.reroute <= 0) reroute(arc);
if (arc.timer <= 0) {
arc.on = false;
arc.timer = (260 + rnd() * 1900) * (1.3 - level);
}
} else if (arc.timer <= 0 && strike(arc)) {
arc.timer = (480 + rnd() * 1300) * (0.55 + level);
}
arc.flash *= Math.exp(-dt / 110);
}
};
/** Maps a bolt from the arc's local frame into canvas pixels. */
const trace = (path: Path2D, pts: number[], ax: number, ay: number, dx: number, dy: number) => {
for (let i = 0; i < pts.length; i += 2) {
const x = ax + dx * pts[i] - dy * pts[i + 1];
const y = ay + dy * pts[i] + dx * pts[i + 1];
if (i === 0) path.moveTo(x, y);
else path.lineTo(x, y);
}
};
// Additive layers from a wide faint halo to a thin bright core: the glow without blur filters.
const LAYERS = [
{ width: 16, alpha: 0.045 },
{ width: 7, alpha: 0.11 },
{ width: 3, alpha: 0.32 },
];
const drawArc = (arc: Arc) => {
if (!arc.bolt.main.length) return;
const a = points[arc.a];
const b = arc.b < 0 ? ptr : points[arc.b];
const dx = b.x - a.x;
const dy = b.y - a.y;
const main = new Path2D();
trace(main, arc.bolt.main, a.x, a.y, dx, dy);
const forks = new Path2D();
for (const f of arc.bolt.forks) trace(forks, f, a.x, a.y, dx, dy);
const brightness = (0.6 + 0.4 * level) * arc.flicker * (0.86 + 0.14 * arc.flash);
const thick = (0.75 + 0.55 * level) * (1 + 0.35 * arc.flash);
const tone = dark ? 1 : 0.8;
for (const layer of LAYERS) {
ctx.strokeStyle = rgba(glow, layer.alpha * brightness * tone);
ctx.lineWidth = layer.width * thick;
ctx.stroke(main);
ctx.lineWidth = layer.width * thick * 0.55;
ctx.strokeStyle = rgba(glow, layer.alpha * brightness * tone * 0.7);
ctx.stroke(forks);
}
ctx.strokeStyle = rgba(core, Math.min(1, 0.95 * brightness));
ctx.lineWidth = 1.2 * thick;
ctx.stroke(main);
ctx.lineWidth = 0.8 * thick;
ctx.stroke(forks);
live.add(arc.a);
if (arc.b >= 0) live.add(arc.b);
};
const drawAnchor = (p: { x: number; y: number }, active: boolean) => {
ctx.beginPath();
ctx.arc(p.x, p.y, 7.5, 0, Math.PI * 2);
ctx.strokeStyle = rgba(glow, dark ? 0.35 : 0.5);
ctx.lineWidth = 1;
ctx.stroke();
if (active) {
for (const [r, alpha] of [
[16, 0.06],
[9, 0.14],
] as const) {
ctx.beginPath();
ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
ctx.fillStyle = rgba(glow, alpha * (dark ? 1 : 0.8));
ctx.fill();
}
}
ctx.beginPath();
ctx.arc(p.x, p.y, 3, 0, Math.PI * 2);
ctx.fillStyle = rgba(active ? core : glow, active ? 1 : dark ? 0.6 : 0.75);
ctx.fill();
};
const draw = () => {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
ctx.globalCompositeOperation = dark ? "lighter" : "source-over";
ctx.lineCap = "round";
ctx.lineJoin = "round";
live.clear();
for (const arc of arcs) if (arc.on) drawArc(arc);
if (pointerArc.on) drawArc(pointerArc);
if (showAnchors) for (let i = 0; i < points.length; i++) drawAnchor(points[i], live.has(i));
ctx.globalCompositeOperation = "source-over";
};
const frame = (now: number) => {
raf = 0;
// Timers run on elapsed milliseconds, so the crackle is the same at 60, 120 or 144Hz.
const dt = Math.min(64, last ? now - last : 16.7);
last = now;
time += dt;
update(dt);
draw();
schedule();
};
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();
if (Math.abs(rect.width - width) < 1 && Math.abs(rect.height - height) < 1 && width > 0) return;
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));
layout.forEach((a, i) => {
points[i].x = a.x * width;
points[i].y = a.y * height;
});
// The still frame (reduced motion) wires every edge once, with a fixed seed.
if (reduceMotion) for (const arc of arcs) if (!arc.bolt.main.length) reroute(arc);
draw();
schedule();
};
const locate = (e: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
ptr.x = e.clientX - rect.left;
ptr.y = e.clientY - rect.top;
return ptr.x >= 0 && ptr.y >= 0 && ptr.x <= rect.width && ptr.y <= rect.height;
};
const onPointerMove = (e: PointerEvent) => {
if (e.pointerType === "touch" && !ptr.touch) return;
ptr.inside = locate(e);
};
// Touch has no hover: an arc follows the finger only while it is pressed.
const onPointerDown = (e: PointerEvent) => {
if (e.pointerType !== "touch") return;
ptr.touch = true;
ptr.inside = locate(e);
};
const onPointerUp = (e: PointerEvent) => {
if (e.pointerType !== "touch") return;
ptr.touch = false;
ptr.inside = false;
};
const onPointerLeave = () => (ptr.inside = false);
const onVisibility = () => (document.hidden ? stop() : schedule());
const onThemeChange = () => {
readTheme();
if (!raf) draw();
};
const listenPointer = reach > 0 && !reduceMotion;
// Pointer listeners only exist while the canvas is on screen.
const listen = (on: boolean) => {
if (on) {
window.addEventListener("pointermove", onPointerMove, { passive: true });
root.addEventListener("pointerdown", onPointerDown);
window.addEventListener("pointerup", onPointerUp);
window.addEventListener("pointercancel", onPointerUp);
document.documentElement.addEventListener("pointerleave", onPointerLeave);
} else {
window.removeEventListener("pointermove", onPointerMove);
root.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("pointercancel", onPointerUp);
document.documentElement.removeEventListener("pointerleave", onPointerLeave);
}
};
let listening = false;
const io = new IntersectionObserver(([entry]) => {
inView = entry.isIntersecting;
if (listenPointer && inView !== listening) {
listening = inView;
listen(inView);
}
if (inView) schedule();
else stop();
});
readTheme();
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();
document.removeEventListener("visibilitychange", onVisibility);
if (listening) listen(false);
};
}, [intensity, branching, color, nodes, anchorKey, reach, showAnchors, reduceMotion]);
return (
<div ref={rootRef} 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>
);
}