Arc
An electric-storm background: seeded midpoint-displacement lightning bolts fork down from a faint cloud glow toward random ground points, drawn as layered strokes (wide glow, colored haze, white-hot core) with flicker re-strikes and afterimage persistence. Click to call a strike to your cursor. Photosensitivity-safe by design: shaped pulse trains (max 3 peaks, 130ms+ apart), a hard 1.25s strike cooldown, and a flashCap clamp on full-frame luminance. Canvas 2D, idle-silent between strikes, offscreen-paused, reduced-motion poster frame.
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 interface ArcProps extends React.ComponentPropsWithoutRef<"div"> {
/**
* Ambient strikes per second. Clamped 0–0.8; on top of that a hard 1.25s
* minimum gap between strike starts is always enforced (see the
* photosensitivity note on the component). 0 = no ambient strikes
* (click-only when `interactive`). @default 0.3
*/
frequency?: number;
/** How eagerly bolts fork into side branches, 0 (bare channel) – 1 (heavily forked). @default 0.55 */
branchiness?: number;
/** Jaggedness of the bolt path — midpoint-displacement amplitude multiplier, 0–2. @default 1 */
jag?: number;
/** Width of the outer glow stroke in CSS px. @default 26 */
glowRadius?: number;
/**
* `[core, haze, background]`. Core is the white-hot channel, haze the
* colored glow/sky wash, background the storm-sky bed.
* @default ["#dbeafe", "#3b82f6", "#05070d"]
*/
colors?: string[];
/** Click/tap calls a strike down to the cursor (with a charge-up shimmer). @default true */
interactive?: boolean;
/** Flicker/decay tempo and ambient-cadence multiplier. Safety clamps below are absolute and unaffected. @default 1 */
speed?: number;
/** Overall brightness of strokes, impact glow and sky wash, 0–2. @default 1 */
intensity?: number;
/**
* Photosensitivity clamp: the maximum opacity of the full-frame sky-wash
* flash. Hard-clamped to ≤0.25 internally — no prop value can produce a
* large-area luminance jump. @default 0.14
*/
flashCap?: number;
/** Freeze at the static poster frame (one forked bolt at full brightness). @default false */
paused?: boolean;
/**
* Deterministic seed for bolt geometry and the strike schedule. Give each
* instance on a page a different seed to desynchronize their storms.
* @default 1
*/
seed?: number;
/** devicePixelRatio ceiling. Clamped 1–2. @default 2 */
dpr?: number;
}
const DEFAULT_COLORS = ["#dbeafe", "#3b82f6", "#05070d"];
/**
* Hard minimum gap (real seconds) between any two strike starts — ambient and
* click strikes share it. With ≤3 brightness peaks per strike, all inside the
* first ~0.42s, this guarantees any sliding one-second window contains at most
* 3 peaks. Never relax this.
*/
const MIN_STRIKE_GAP = 1.25;
/** Peaks per strike never exceed 3 and are never closer than this (real seconds). */
const MIN_PEAK_SPACING = 0.13;
/** Absolute ceiling on the flashCap prop. */
const FLASH_CAP_MAX = 0.25;
/** Per-line-depth width and brightness falloff: main channel, branch, sub-branch. */
const LEVEL_W = [1, 0.55, 0.35];
const LEVEL_B = [1, 0.55, 0.35];
/** Module-scope mount counter → deterministic schedule desync between instances. */
let instanceCounter = 0;
/** Deterministic mulberry32 PRNG — same seed always produces the same storm. */
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function clamp(v: number, lo: number, hi: number): number {
return Math.min(hi, Math.max(lo, v));
}
/** Normalize any CSS color string to [r,g,b] using a throwaway 1×1 read. */
function colorToRgb(ctx: CanvasRenderingContext2D, color: string): [number, number, number] {
ctx.clearRect(0, 0, 1, 1);
ctx.fillStyle = color;
ctx.fillRect(0, 0, 1, 1);
const d = ctx.getImageData(0, 0, 1, 1).data;
return [d[0], d[1], d[2]];
}
/** Fast attack (~30ms) then exponential decay — one flicker peak of a strike. */
function pulseShape(u: number, tau: number): number {
if (u <= 0) return 0;
const rise = 0.03;
if (u < rise) return u / rise;
return Math.exp(-(u - rise) / tau);
}
interface Pulse {
t: number; // seconds after strike start
amp: number;
}
interface Polyline {
pts: number[]; // [x0,y0,x1,y1,...] device px
level: number; // 0 main channel, 1 branch, 2 sub-branch
}
interface Strike {
lines: Polyline[];
pulses: Pulse[];
start: number;
end: number;
originX: number;
targetX: number;
targetY: number;
}
interface Charge {
x: number;
y: number;
start: number;
fireAt: number;
}
interface Knobs {
speed: number;
intensity: number;
frequency: number;
jag: number;
branchiness: number;
glowRadius: number;
flashCap: number;
}
interface Internals {
ctx: CanvasRenderingContext2D;
w: number;
h: number;
scale: number;
seed: number;
bed: HTMLCanvasElement; // storm sky: bg + cloud glow, the fade target
wash: HTMLCanvasElement; // big soft haze sprite for the sky flash
spark: HTMLCanvasElement; // hot core sprite for impact / charge shimmer
coreStyle: string;
hazeStyle: string;
strikes: Strike[];
charge: Charge | null;
rand: () => number;
nextStrike: number;
lastStrikeStart: number;
fadeUntil: number; // afterimage still decaying until this time
last: number;
now: number;
}
/** Recursive midpoint displacement: jagged polyline from (x1,y1) to (x2,y2). */
function displacedPath(
rand: () => number,
x1: number,
y1: number,
x2: number,
y2: number,
jag: number
): number[] {
const pts: number[] = [x1, y1];
const len = Math.hypot(x2 - x1, y2 - y1) || 1;
const gens = clamp(Math.round(Math.log2(len / 10)), 3, 7);
const sub = (ax: number, ay: number, bx: number, by: number, disp: number, gen: number) => {
if (gen === 0) {
pts.push(bx, by);
return;
}
const dx = bx - ax;
const dy = by - ay;
const l = Math.hypot(dx, dy) || 1;
const off = (rand() * 2 - 1) * disp;
const mx = (ax + bx) / 2 + (-dy / l) * off;
const my = (ay + by) / 2 + (dx / l) * off;
sub(ax, ay, mx, my, disp * 0.52, gen - 1);
sub(mx, my, bx, by, disp * 0.52, gen - 1);
};
sub(x1, y1, x2, y2, len * 0.16 * jag, gens);
return pts;
}
/** Grow branches off a parent polyline (recursing at most to sub-branches). */
function addBranches(
rand: () => number,
parent: number[],
level: number,
jag: number,
branchiness: number,
groundY: number,
scale: number,
lines: Polyline[]
): void {
if (level > 2 || branchiness <= 0) return;
const n = parent.length / 2;
if (n < 6) return;
const maxB = level === 1 ? Math.round(1 + branchiness * 5) : 1;
const lo = Math.max(1, Math.floor(n * 0.15));
const hi = Math.min(n - 2, Math.floor(n * 0.85));
let made = 0;
for (let i = lo; i < hi && made < maxB; i += 2) {
if (rand() > branchiness * 0.3) continue;
const bx = parent[i * 2];
const by = parent[i * 2 + 1];
// Local travel direction, rotated off to a side.
const dx = parent[(i + 1) * 2] - parent[(i - 1) * 2];
const dy = parent[(i + 1) * 2 + 1] - parent[(i - 1) * 2 + 1];
const dl = Math.hypot(dx, dy) || 1;
const angle = (rand() < 0.5 ? -1 : 1) * (0.35 + rand() * 0.55);
const ca = Math.cos(angle);
const sa = Math.sin(angle);
const ux = (dx * ca - dy * sa) / dl;
const uy = (dx * sa + dy * ca) / dl;
const room = Math.max(groundY - by, 40 * scale);
const len = Math.max(30 * scale, room * (0.2 + rand() * 0.28) * (level === 1 ? 1 : 0.55));
const path = displacedPath(rand, bx, by, bx + ux * len, by + uy * len, jag * 1.15);
lines.push({ pts: path, level });
addBranches(rand, path, level + 1, jag, branchiness, groundY, scale, lines);
made++;
}
}
function buildBoltLines(
rand: () => number,
ox: number,
oy: number,
tx: number,
ty: number,
jag: number,
branchiness: number,
groundY: number,
scale: number
): Polyline[] {
const lines: Polyline[] = [];
const main = displacedPath(rand, ox, oy, tx, ty, jag);
lines.push({ pts: main, level: 0 });
addBranches(rand, main, 1, jag, branchiness, groundY, scale, lines);
return lines;
}
/**
* 1–3 flicker peaks. Spacing is hard-floored at MIN_PEAK_SPACING in *real*
* seconds (never scaled by speed) and the last peak lands ≤ ~0.42s in.
*/
function buildPulses(rand: () => number): Pulse[] {
const pulses: Pulse[] = [{ t: 0, amp: 1 }];
if (rand() < 0.85) {
pulses.push({ t: MIN_PEAK_SPACING + rand() * 0.07, amp: 0.5 + rand() * 0.15 });
if (rand() < 0.5) {
pulses.push({
t: pulses[1].t + MIN_PEAK_SPACING + rand() * 0.09,
amp: 0.28 + rand() * 0.12,
});
}
}
return pulses;
}
function spawnStrike(
it: Internals,
k: Knobs,
ox: number,
tx: number,
ty: number,
now: number
): void {
const lines = buildBoltLines(
it.rand,
ox,
-8 * it.scale,
tx,
ty,
clamp(k.jag, 0, 2),
clamp(k.branchiness, 0, 1),
it.h,
it.scale
);
const pulses = buildPulses(it.rand);
const end = now + pulses[pulses.length - 1].t + 0.55;
it.strikes.push({ lines, pulses, start: now, end, originX: ox, targetX: tx, targetY: ty });
it.lastStrikeStart = now;
it.fadeUntil = Math.max(it.fadeUntil, end + 1.6);
}
function scheduleNext(it: Internals, k: Knobs, now: number): void {
const f = clamp(k.frequency, 0, 0.8) * clamp(k.speed, 0.25, 3);
if (f <= 0.001) {
it.nextStrike = Infinity;
return;
}
const interval = (1 / f) * (0.6 + it.rand() * 0.9);
it.nextStrike = now + Math.max(MIN_STRIKE_GAP, interval);
}
/** Soft radial sprite: `core` = hot center profile, otherwise a wide haze. */
function makeSprite(rgb: [number, number, number], core: boolean): HTMLCanvasElement {
const D = 128;
const c = document.createElement("canvas");
c.width = D;
c.height = D;
const g = c.getContext("2d")!;
const [r, gg, b] = rgb;
const grad = g.createRadialGradient(D / 2, D / 2, 0, D / 2, D / 2, D / 2);
if (core) {
grad.addColorStop(0, `rgba(${r},${gg},${b},1)`);
grad.addColorStop(0.25, `rgba(${r},${gg},${b},0.5)`);
grad.addColorStop(1, `rgba(${r},${gg},${b},0)`);
} else {
grad.addColorStop(0, `rgba(${r},${gg},${b},0.55)`);
grad.addColorStop(0.5, `rgba(${r},${gg},${b},0.16)`);
grad.addColorStop(1, `rgba(${r},${gg},${b},0)`);
}
g.fillStyle = grad;
g.fillRect(0, 0, D, D);
return c;
}
/** Storm-sky bed: near-black bg + faint uneven cloud glow along the top edge. */
function makeBed(
w: number,
h: number,
bg: [number, number, number],
haze: [number, number, number],
seed: number
): HTMLCanvasElement {
const c = document.createElement("canvas");
c.width = w;
c.height = h;
const g = c.getContext("2d")!;
g.fillStyle = `rgb(${bg[0]},${bg[1]},${bg[2]})`;
g.fillRect(0, 0, w, h);
const [hr, hg, hb] = haze;
const band = g.createLinearGradient(0, 0, 0, h * 0.32);
band.addColorStop(0, `rgba(${hr},${hg},${hb},0.08)`);
band.addColorStop(1, `rgba(${hr},${hg},${hb},0)`);
g.fillStyle = band;
g.fillRect(0, 0, w, h * 0.32);
// Two seeded cloud blobs so the glow reads as weather, not a gradient.
const rand = mulberry32(seed ^ 0xc1a0d5ed);
for (let i = 0; i < 2; i++) {
const cx = w * (0.15 + rand() * 0.7);
const R = w * (0.16 + rand() * 0.14);
const blob = g.createRadialGradient(cx, 0, 0, cx, 0, R);
blob.addColorStop(0, `rgba(${hr},${hg},${hb},0.06)`);
blob.addColorStop(1, `rgba(${hr},${hg},${hb},0)`);
g.fillStyle = blob;
g.fillRect(cx - R, 0, R * 2, R);
}
return c;
}
function tracePath(ctx: CanvasRenderingContext2D, pts: number[]): void {
ctx.beginPath();
ctx.moveTo(pts[0], pts[1]);
for (let i = 2; i < pts.length; i += 2) ctx.lineTo(pts[i], pts[i + 1]);
}
/**
* Draw one bolt: sky wash (flashCap-clamped) + impact glow + layered strokes
* (wide low-alpha glow → medium haze → thin white-hot core), all additive.
*/
function drawBolt(
it: Internals,
k: Knobs,
lines: Polyline[],
eMain: number,
eBranch: number,
originX: number,
targetX: number,
targetY: number
): void {
const { ctx } = it;
const intensity = clamp(k.intensity, 0, 2);
const flashCap = clamp(k.flashCap, 0, FLASH_CAP_MAX);
ctx.globalCompositeOperation = "lighter";
// Full-frame component of the flash — the ONLY large-area luminance change,
// hard-clamped by flashCap.
const washA = Math.min(eMain * 0.5 * intensity, flashCap);
if (washA > 0.003) {
const W = it.w * 1.15;
ctx.globalAlpha = washA;
ctx.drawImage(it.wash, originX - W / 2, -W * 0.62, W, W);
}
// Small impact glow where the bolt meets the ground point.
const impactA = Math.min(1, eMain * 0.55 * intensity);
if (impactA > 0.01) {
const S = 90 * it.scale;
ctx.globalAlpha = impactA;
ctx.drawImage(it.spark, targetX - S / 2, targetY - S / 2, S, S);
}
ctx.lineCap = "round";
ctx.lineJoin = "round";
const glowW = Math.max(2, k.glowRadius) * it.scale;
const passes: { color: string; width: number; alpha: number }[] = [
{ color: it.hazeStyle, width: glowW, alpha: 0.1 },
{ color: it.hazeStyle, width: 3.2 * it.scale, alpha: 0.35 },
{ color: it.coreStyle, width: Math.max(1, 1.15 * it.scale), alpha: 0.95 },
];
for (const line of lines) {
const e = line.level === 0 ? eMain : eBranch * LEVEL_B[line.level];
if (e < 0.01) continue;
tracePath(ctx, line.pts);
for (const p of passes) {
ctx.globalAlpha = Math.min(1, p.alpha * e * intensity);
ctx.strokeStyle = p.color;
ctx.lineWidth = p.width * LEVEL_W[line.level];
ctx.stroke();
}
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "source-over";
}
/**
* Poster frame for reduced motion / paused: the sky bed plus one seeded,
* well-forked bolt frozen at full brightness with its glow — no flashing.
*/
function drawStatic(it: Internals, k: Knobs): void {
const { ctx } = it;
ctx.globalCompositeOperation = "source-over";
ctx.globalAlpha = 1;
ctx.drawImage(it.bed, 0, 0);
const rand = mulberry32(it.seed ^ 0x51ab7e11);
const ox = it.w * (0.3 + rand() * 0.4);
const tx = clamp(ox + (rand() - 0.5) * it.w * 0.36, it.w * 0.08, it.w * 0.92);
const lines = buildBoltLines(
rand,
ox,
-8 * it.scale,
tx,
it.h * 0.97,
clamp(k.jag, 0, 2),
Math.max(0.6, clamp(k.branchiness, 0, 1)), // poster is always visibly forked
it.h,
it.scale
);
drawBolt(it, k, lines, 1, 0.9, ox, tx, it.h * 0.97);
}
/** One animated frame. Idle-silent: early-exits without touching the canvas between strikes. */
function stepFrame(it: Internals, k: Knobs, now: number, dt: number): void {
// Ambient schedule (a pending click charge takes priority over ambient).
if (!it.charge && now >= it.nextStrike && now - it.lastStrikeStart >= MIN_STRIKE_GAP) {
const tx = it.w * (0.08 + it.rand() * 0.84);
const ty = it.h * (0.86 + it.rand() * 0.14);
const ox = clamp(tx + (it.rand() - 0.5) * it.w * 0.5, it.w * 0.04, it.w * 0.96);
spawnStrike(it, k, ox, tx, ty, now);
scheduleNext(it, k, now);
}
// Fire a charged click strike once its cooldown-respecting moment arrives.
if (it.charge && now >= it.charge.fireAt && now - it.lastStrikeStart >= MIN_STRIKE_GAP) {
const c = it.charge;
it.charge = null;
const ox = clamp(c.x + (it.rand() - 0.5) * it.w * 0.4, it.w * 0.04, it.w * 0.96);
spawnStrike(it, k, ox, c.x, c.y, now);
it.nextStrike = Math.max(it.nextStrike, now + MIN_STRIKE_GAP);
}
if (it.strikes.length) it.strikes = it.strikes.filter(s => now < s.end);
const busy = it.strikes.length > 0 || it.charge !== null || now < it.fadeUntil;
if (!busy) return; // idle-silent between strikes
// Afterimage persistence: fade toward the storm bed instead of clearing.
const { ctx } = it;
ctx.globalCompositeOperation = "source-over";
ctx.globalAlpha = 1 - Math.exp(-dt / 0.22);
ctx.drawImage(it.bed, 0, 0);
ctx.globalAlpha = 1;
const tau = 0.08 / clamp(k.speed, 0.4, 2.5);
for (const s of it.strikes) {
const t = now - s.start;
let eMain = 0;
for (const p of s.pulses) eMain = Math.max(eMain, p.amp * pulseShape(t - p.t, tau));
// Branches light up on the first stroke only (like real return strokes).
const eBranch = pulseShape(t, tau);
if (eMain < 0.004) continue;
drawBolt(it, k, s.lines, eMain, eBranch, s.originX, s.targetX, s.targetY);
}
// Charge-up shimmer at a pending click target: a small, smooth ~2.4Hz swell.
if (it.charge) {
const cs = now - it.charge.start;
const ramp = Math.min(1, cs / 0.12);
const pulse = 0.72 + 0.28 * Math.sin(cs * Math.PI * 2 * 2.4);
const a = Math.min(1, 0.5 * ramp * pulse * clamp(k.intensity, 0, 2));
const S = (30 + 6 * Math.sin(cs * Math.PI * 2 * 1.1)) * it.scale;
ctx.globalCompositeOperation = "lighter";
ctx.globalAlpha = a;
ctx.drawImage(it.spark, it.charge.x - S / 2, it.charge.y - S / 2, S, S);
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "source-over";
}
}
/**
* Arc — an electric-storm background. Seeded midpoint-displacement lightning
* bolts fork down from a faint cloud glow toward random ground points, drawn
* as layered strokes (wide low-alpha glow → colored haze → 1px white-hot core)
* with flicker re-strikes and afterimage persistence via destination fade.
* Click/tap calls a strike to the cursor after a charge-up shimmer.
*
* **Photosensitivity-safe by design** (unlike the strobing effects this genre
* is known for): each strike is a shaped pulse train of at most 3 brightness
* peaks spaced ≥130ms apart, strike starts are hard-clamped ≥1.25s apart
* (ambient and click strikes share the cooldown) so any one-second window
* contains at most 3 peaks — within WCAG 2.3.1's three-flashes threshold —
* and the only full-frame luminance component (the sky wash) is opacity-capped
* by `flashCap` (hard ceiling 0.25). `speed` and `frequency` cannot defeat
* these clamps. Reduced motion shows a static poster bolt with no flashing.
*
* Canvas 2D; idle-silent between strikes (frames early-exit without drawing);
* pauses offscreen and on hidden tabs; fully seeded (deterministic, SSR-safe);
* tears down observers and listeners on unmount.
*/
export function Arc({
frequency = 0.3,
branchiness = 0.55,
jag = 1,
glowRadius = 26,
colors = DEFAULT_COLORS,
interactive = true,
speed = 1,
intensity = 1,
flashCap = 0.14,
paused = false,
seed = 1,
dpr = 2,
className,
style,
...props
}: ArcProps) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const internalsRef = React.useRef<Internals | null>(null);
const reducedMotion = useReducedMotion();
const frozen = paused || reducedMotion;
const colorsKey = colors.join(",");
const knobsRef = React.useRef<Knobs>({
speed,
intensity,
frequency,
jag,
branchiness,
glowRadius,
flashCap,
});
knobsRef.current = { speed, intensity, frequency, jag, branchiness, glowRadius, flashCap };
const tick = React.useCallback((elapsed: number) => {
const it = internalsRef.current;
if (!it) return;
const dt = Math.min(elapsed - it.last, 0.05);
it.last = elapsed;
it.now = elapsed;
if (dt <= 0) return;
stepFrame(it, knobsRef.current, elapsed, dt);
}, []);
const containerRef = useVisibilityPause<HTMLDivElement>(tick, { paused: frozen });
// Mount-once canvas setup; rebuild bed/sprites on size / palette / seed change.
React.useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const ctx = canvas.getContext("2d", { alpha: false });
if (!ctx) return;
const scale = clamp(
Math.min(typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1, dpr),
1,
2
);
const palette = colors.length >= 2 ? colors : DEFAULT_COLORS;
const coreRgb = colorToRgb(ctx, palette[0] ?? DEFAULT_COLORS[0]);
const hazeRgb = colorToRgb(ctx, palette[1] ?? DEFAULT_COLORS[1]);
const bgRgb = colorToRgb(ctx, palette[2] ?? DEFAULT_COLORS[2]);
const wash = makeSprite(hazeRgb, false);
const spark = makeSprite(coreRgb, true);
const inst = instanceCounter++;
const build = () => {
const w = Math.max(1, Math.round(container.clientWidth * scale));
const h = Math.max(1, Math.round(container.clientHeight * scale));
canvas.width = w;
canvas.height = h;
const prev = internalsRef.current;
const rand = mulberry32(seed);
const it: Internals = {
ctx,
w,
h,
scale,
seed,
bed: makeBed(w, h, bgRgb, hazeRgb, seed),
wash,
spark,
coreStyle: `rgb(${coreRgb[0]},${coreRgb[1]},${coreRgb[2]})`,
hazeStyle: `rgb(${hazeRgb[0]},${hazeRgb[1]},${hazeRgb[2]})`,
strikes: [],
charge: null,
rand,
// Carry the clock/schedule across resizes; stagger fresh instances so
// multiple storms on one page desynchronize deterministically.
nextStrike: prev
? Math.max(prev.nextStrike, prev.now + 0.4)
: 0.35 + rand() * 0.9 + (inst % 4) * 0.6,
lastStrikeStart: prev?.lastStrikeStart ?? -MIN_STRIKE_GAP,
fadeUntil: prev?.fadeUntil ?? 0,
last: prev?.last ?? 0,
now: prev?.now ?? 0,
};
internalsRef.current = it;
// Paint immediately so mount/resize never flashes empty.
if (frozen) drawStatic(it, knobsRef.current);
else {
ctx.globalAlpha = 1;
ctx.drawImage(it.bed, 0, 0);
}
};
const ro = new ResizeObserver(build);
ro.observe(container);
build();
return () => {
ro.disconnect();
internalsRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [seed, colorsKey, dpr]);
// Freeze/unfreeze: draw the poster when frozen (and on poster-affecting prop
// tweaks); on unfreeze let the poster decay like an afterimage.
React.useEffect(() => {
const it = internalsRef.current;
if (!it) return;
if (frozen) drawStatic(it, knobsRef.current);
else it.fadeUntil = Math.max(it.fadeUntil, it.now + 2);
}, [frozen, jag, branchiness, glowRadius, intensity, flashCap]);
// Click/tap → charge shimmer → strike to the cursor.
React.useEffect(() => {
const el = containerRef.current;
if (!el || !interactive || frozen) return;
const onPointerDown = (e: PointerEvent) => {
const it = internalsRef.current;
if (!it) return;
const rect = el.getBoundingClientRect();
const x = ((e.clientX - rect.left) / Math.max(rect.width, 1)) * it.w;
const y = ((e.clientY - rect.top) / Math.max(rect.height, 1)) * it.h;
// The shimmer stretches to honor the global strike cooldown.
const fireAt = Math.max(it.now + 0.28, it.lastStrikeStart + MIN_STRIKE_GAP);
it.charge = { x, y, start: it.now, fireAt };
it.fadeUntil = Math.max(it.fadeUntil, fireAt + 2);
};
el.addEventListener("pointerdown", onPointerDown);
return () => el.removeEventListener("pointerdown", onPointerDown);
}, [interactive, frozen, containerRef]);
return (
<div
ref={containerRef}
aria-hidden
data-crucible="arc"
className={cn("absolute inset-0 -z-10 overflow-hidden", className)}
style={style}
{...props}
>
<canvas ref={canvasRef} className="block h-full w-full" />
</div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/arcProps
| Prop | Type | Default | Description |
|---|---|---|---|
| frequency | number | 0.3 | Ambient strikes per second. Clamped 0–0.8; on top of that a hard 1.25s minimum gap between strike starts is always enforced (see the photosensitivity note on the component). 0 = no ambient strikes (click-only when interactive). |
| branchiness | number | 0.55 | How eagerly bolts fork into side branches, 0 (bare channel) – 1 (heavily forked). |
| jag | number | 1 | Jaggedness of the bolt path — midpoint-displacement amplitude multiplier, 0–2. |
| glowRadius | number | 26 | Width of the outer glow stroke in CSS px. |
| colors | string[] | ["#dbeafe", "#3b82f6", "#05070d"] | [core, haze, background]. Core is the white-hot channel, haze the colored glow/sky wash, background the storm-sky bed. |
| interactive | boolean | true | Click/tap calls a strike down to the cursor (with a charge-up shimmer). |
| speed | number | 1 | Flicker/decay tempo and ambient-cadence multiplier. Safety clamps below are absolute and unaffected. |
| intensity | number | 1 | Overall brightness of strokes, impact glow and sky wash, 0–2. |
| flashCap | number | 0.14 | Photosensitivity clamp: the maximum opacity of the full-frame sky-wash flash. Hard-clamped to ≤0.25 internally — no prop value can produce a large-area luminance jump. |
| paused | boolean | false | Freeze at the static poster frame (one forked bolt at full brightness). |
| seed | number | 1 | Deterministic seed for bolt geometry and the strike schedule. Give each instance on a page a different seed to desynchronize their storms. |
| dpr | number | 2 | devicePixelRatio ceiling. Clamped 1–2. |
Also accepts all props of React.ComponentPropsWithoutRef<"div"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, pauses offscreen and on hidden tabs, and passes className through.