# Arc — Animated Background for React

> 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. Clicks stack (up to three queued strikes, still cooldown-clamped), a `strike()` ref handle calls bolts programmatically, and a ghost click idles the storm when untouched.

- Page: https://crucibleui.com/components/backgrounds/arc
- Category: [backgrounds](https://crucibleui.com/backgrounds)
- Tier: free (full source)
- Engine: canvas

## Install

```bash
npx shadcn@latest add @crucible/arc
```

Registry JSON: https://crucibleui.com/r/arc.json

Import from `@/components/crucible/arc`.

Backgrounds render `absolute inset-0 -z-10`. Their parent must be a stacking context — wrap in `relative isolate` and put your content in a `relative z-10` child, or the background disappears behind an opaque ancestor.

## Props (`ArcProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `ref?` | `React.Ref<ArcHandle>` | — | Imperative handle — see {@link ArcHandle}. |
| `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. |
| `idle?` | `boolean` | `true` | Idle autoplay: after ~1.5s with no real pointer, a seeded ghost "clicks" every few seconds — a charge shimmer drifting on a slow Lissajous path, then a strike to it — so the click interaction shows itself in thumbnails and heroes. Ghost strikes share the cooldown and pulse clamps above. Only when `interactive`; never under reduced motion. |

Also accepts: `React.ComponentPropsWithoutRef<"div">`.

## Usage example

`DemoScene` is a site-only preview harness, not part of the component — replace it with your own layout.

```tsx
import { Arc } from "@/components/crucible/arc";
import { DemoScene } from "@/components/registry/demo-scene";

export default function ArcDemo() {
  return (
    <DemoScene
      background={<Arc />}
      headline="Lightning on a storm schedule"
      subhead="Bolts fork down on their own — click anywhere to call one to your cursor; click again to queue more."
    />
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through. Pauses offscreen and on hidden tabs, clamps device pixel ratio, and tears down its GL/canvas context on unmount.

## Source (`arc.tsx`)

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/hooks/use-reduced-motion";
import { useVisibilityPause } from "@/hooks/use-visibility-pause";

/** Imperative verbs exposed through the `ref` prop. */
export interface ArcHandle {
  /**
   * Call a charged strike down to `(x, y)` — fractions of the element, `0,0`
   * top-left. Defaults to a seeded random ground point. Calls stack: up to 3
   * charges queue up and fire in turn, each still honoring the 1.25s strike
   * cooldown. No-op while paused or under reduced motion.
   */
  strike: (x?: number, y?: number) => void;
}

export interface ArcProps extends React.ComponentPropsWithoutRef<"div"> {
  /** Imperative handle — see {@link ArcHandle}. */
  ref?: React.Ref<ArcHandle>;
  /**
   * 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;
  /**
   * Idle autoplay: after ~1.5s with no real pointer, a seeded ghost "clicks"
   * every few seconds — a charge shimmer drifting on a slow Lissajous path,
   * then a strike to it — so the click interaction shows itself in thumbnails
   * and heroes. Ghost strikes share the cooldown and pulse clamps above. Only
   * when `interactive`; never under reduced motion. @default true
   */
  idle?: boolean;
}

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;
/** Most charged strikes that may queue up from stacked clicks / handle calls. */
const MAX_CHARGES = 3;
/** Ghost takes over this long (ms) after the last real pointer activity. */
const GHOST_DELAY_MS = 1500;

/** 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;
  /** Queued by the idle ghost — dropped the moment a real click arrives. */
  ghost?: boolean;
}

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[];
  /** Pending charged strikes, oldest first (stacked clicks queue up). */
  charges: Charge[];
  rand: () => number;
  /** Separate seeded stream for the ghost so it never perturbs the storm. */
  ghostRand: () => number;
  nextGhost: 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, ghost: boolean): void {
  // Idle ghost: a seeded "click" on a slow Lissajous path, queued exactly
  // like a real one so it inherits every photosensitivity clamp.
  if (ghost && it.charges.length === 0 && now >= it.nextGhost) {
    const fx = 0.5 + 0.36 * Math.sin(now * 0.23) * Math.cos(now * 0.071);
    const fy = 0.66 + 0.2 * Math.sin(now * 0.17 + 1.3);
    queueCharge(it, fx * it.w, fy * it.h, true);
    it.nextGhost = now + 4.2 + it.ghostRand() * 3;
  }
  // Ambient schedule (a pending click charge takes priority over ambient).
  if (it.charges.length === 0 && 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.
  const head = it.charges[0];
  if (head && now >= head.fireAt && now - it.lastStrikeStart >= MIN_STRIKE_GAP) {
    const c = head;
    it.charges.shift();
    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.charges.length > 0 || 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 each pending click target: a small, smooth ~2.4Hz swell.
  for (const c of it.charges) {
    const cs = Math.max(0, now - c.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, c.x - S / 2, c.y - S / 2, S, S);
    ctx.globalAlpha = 1;
    ctx.globalCompositeOperation = "source-over";
  }
}

/**
 * Queue a charged strike at device-px `(x, y)`. Stacked calls line up behind
 * each other, each a full cooldown after the previous one — so stacking can
 * never beat the strike-gap clamp. Capped at MAX_CHARGES.
 */
function queueCharge(it: Internals, x: number, y: number, ghost = false): void {
  if (it.charges.length >= MAX_CHARGES) return;
  const prev = it.charges[it.charges.length - 1];
  const fireAt = Math.max(
    it.now + 0.28,
    it.lastStrikeStart + MIN_STRIKE_GAP,
    prev ? prev.fireAt + MIN_STRIKE_GAP : -Infinity
  );
  it.charges.push({ x, y, start: it.now, fireAt, ghost });
  it.fadeUntil = Math.max(it.fadeUntil, fireAt + 2);
}

/**
 * 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,
  idle = true,
  ref,
  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 };
  // Ghost clicks demo the click interaction, so they only run when it exists.
  const ghostOnRef = React.useRef(idle && interactive);
  ghostOnRef.current = idle && interactive;
  // performance.now() of the last real pointer activity; -Infinity = never.
  const lastRealPointerRef = React.useRef(-Infinity);
  // A parked cursor still counts as present — the ghost never clicks under it.
  const insideRef = React.useRef(false);

  const tick = React.useCallback((elapsed: number) => {
    const it = internalsRef.current;
    if (!it) return;
    // Re-seat if the loop clock ever restarts below our last sample.
    if (elapsed < it.last) it.last = elapsed;
    const dt = Math.min(elapsed - it.last, 0.05);
    it.last = elapsed;
    it.now = elapsed;
    if (dt <= 0) return;
    const ghost =
      ghostOnRef.current &&
      !insideRef.current &&
      performance.now() - lastRealPointerRef.current > GHOST_DELAY_MS;
    stepFrame(it, knobsRef.current, elapsed, dt, ghost);
  }, []);

  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: [],
        charges: [],
        rand,
        ghostRand: prev?.ghostRand ?? mulberry32(seed ^ 0x9e3779b9),
        // First ghost click lands soon so thumbnails show the interaction.
        nextGhost: prev?.nextGhost ?? 2.2 + (inst % 3) * 0.7,
        // 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;
      lastRealPointerRef.current = performance.now();
      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 real pointer takes over: drop any pending ghost charge. Clicks
      // stack: each queues behind the last, the shimmer stretching to honor
      // the global strike cooldown.
      it.charges = it.charges.filter((c) => !c.ghost);
      queueCharge(it, x, y);
    };
    // Any real pointer presence holds the ghost off.
    const onPointerMove = () => {
      insideRef.current = true;
      lastRealPointerRef.current = performance.now();
    };
    const onPointerLeave = () => {
      insideRef.current = false;
      lastRealPointerRef.current = performance.now();
    };
    el.addEventListener("pointerdown", onPointerDown);
    el.addEventListener("pointermove", onPointerMove);
    el.addEventListener("pointerleave", onPointerLeave);
    return () => {
      el.removeEventListener("pointerdown", onPointerDown);
      el.removeEventListener("pointermove", onPointerMove);
      el.removeEventListener("pointerleave", onPointerLeave);
    };
  }, [interactive, frozen, containerRef]);

  React.useImperativeHandle(
    ref,
    () => ({
      strike: (x?: number, y?: number) => {
        const it = internalsRef.current;
        if (!it || frozen) return;
        const fx = x ?? 0.12 + it.ghostRand() * 0.76;
        const fy = y ?? 0.86 + it.ghostRand() * 0.12;
        queueCharge(it, clamp(fx, 0, 1) * it.w, clamp(fy, 0, 1) * it.h);
      },
    }),
    [frozen]
  );

  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>
  );
}
```
