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