Workspace / component / animations

Electric Border

A jagged electric arc traces the border of its child on a canvas overlay, flickering with controlled randomness. Voltage is a prop.

Workspacecomponentanimations

paxfx add blocks/component-animations-electric-borderlocal only

Tier: medium. Reduced motion: A steady drawn border with a faint glow, no arc.

"use client";

import { useEffect, useRef, type ReactNode } from "react";
import { cn, useInView, useReducedMotion } from "@pax-sh/blocks";

export interface ElectricBorderProps {
  /** 0 to 1; jitter amplitude of the arc. */
  voltage?: number;
  /** Border corner radius in pixels; match your child's rounding. */
  cornerRadius?: number;
  className?: string;
  children?: ReactNode;
}

function mulberry32(seed: 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;
  };
}

/** Sample `steps` points along a rounded-rect perimeter. */
function roundedRectPoints(
  w: number,
  h: number,
  inset: number,
  radius: number,
  steps: number,
): { x: number; y: number }[] {
  const x0 = inset;
  const y0 = inset;
  const x1 = w - inset;
  const y1 = h - inset;
  const r = Math.min(radius, (x1 - x0) / 2, (y1 - y0) / 2);
  const straightW = x1 - x0 - 2 * r;
  const straightH = y1 - y0 - 2 * r;
  const arc = (Math.PI / 2) * r;
  const total = 2 * straightW + 2 * straightH + 4 * arc;
  const points: { x: number; y: number }[] = [];
  for (let i = 0; i < steps; i += 1) {
    let d = (i / steps) * total;
    if (d < straightW) {
      points.push({ x: x0 + r + d, y: y0 });
      continue;
    }
    d -= straightW;
    if (d < arc) {
      const a = -Math.PI / 2 + d / r;
      points.push({ x: x1 - r + Math.cos(a) * r, y: y0 + r + Math.sin(a) * r });
      continue;
    }
    d -= arc;
    if (d < straightH) {
      points.push({ x: x1, y: y0 + r + d });
      continue;
    }
    d -= straightH;
    if (d < arc) {
      const a = d / r;
      points.push({ x: x1 - r + Math.cos(a) * r, y: y1 - r + Math.sin(a) * r });
      continue;
    }
    d -= arc;
    if (d < straightW) {
      points.push({ x: x1 - r - d, y: y1 });
      continue;
    }
    d -= straightW;
    if (d < arc) {
      const a = Math.PI / 2 + d / r;
      points.push({ x: x0 + r + Math.cos(a) * r, y: y1 - r + Math.sin(a) * r });
      continue;
    }
    d -= arc;
    if (d < straightH) {
      points.push({ x: x0, y: y1 - r - d });
      continue;
    }
    d -= straightH;
    const a = Math.PI + d / r;
    points.push({ x: x0 + r + Math.cos(a) * r, y: y0 + r + Math.sin(a) * r });
  }
  return points;
}

/**
 * A jagged electric arc traces the rounded border of its child on a canvas
 * overlay. The path is a dense polyline sampled from the rounded rect,
 * displaced along its normal with seeded jitter that re-rolls at 30fps,
 * drawn in two passes (wide faint glow, thin bright core) with additive
 * compositing. Reduced motion draws one steady border pass with a faint
 * glow and never starts the loop.
 */
export function ElectricBorder({
  voltage = 0.7,
  cornerRadius = 16,
  className,
  children,
}: ElectricBorderProps) {
  const reduced = useReducedMotion();
  const [inViewRef, inView] = useInView<HTMLDivElement>({ threshold: 0.2 });
  const canvasRef = useRef<HTMLCanvasElement>(null);

  const amp = Math.min(1, Math.max(0, voltage));

  useEffect(() => {
    const root = inViewRef.current;
    const canvas = canvasRef.current;
    if (!root || !canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const margin = 10;
    let width = 0;
    let height = 0;
    let raf = 0;
    let frameIndex = 0;
    let lastRoll = 0;

    const drawArc = (jitterSeed: number) => {
      ctx.clearRect(0, 0, width, height);
      const steps = Math.max(48, Math.round((width + height) / 7));
      const base = roundedRectPoints(width, height, margin, cornerRadius, steps);
      const rand = mulberry32(jitterSeed);
      const jittered = base.map((p, i) => {
        const next = base[(i + 1) % base.length] ?? p;
        const tx = next.x - p.x;
        const ty = next.y - p.y;
        const len = Math.hypot(tx, ty) || 1;
        const offset = (rand() - 0.5) * 2 * (1.5 + amp * 5.5);
        return { x: p.x + (-ty / len) * offset, y: p.y + (tx / len) * offset };
      });

      const trace = (points: { x: number; y: number }[]) => {
        ctx.beginPath();
        for (let i = 0; i < points.length; i += 1) {
          const p = points[i];
          if (!p) continue;
          if (i === 0) ctx.moveTo(p.x, p.y);
          else ctx.lineTo(p.x, p.y);
        }
        ctx.closePath();
        ctx.stroke();
      };

      ctx.globalCompositeOperation = "lighter";
      ctx.lineJoin = "round";
      ctx.strokeStyle = "rgba(200,255,0,0.22)";
      ctx.lineWidth = 5;
      trace(jittered);
      ctx.strokeStyle = "rgba(200,255,0,0.95)";
      ctx.lineWidth = 1.4;
      trace(jittered);
      ctx.globalCompositeOperation = "source-over";
    };

    const drawSteady = () => {
      ctx.clearRect(0, 0, width, height);
      const steps = Math.max(48, Math.round((width + height) / 7));
      const base = roundedRectPoints(width, height, margin, cornerRadius, steps);
      ctx.lineJoin = "round";
      ctx.beginPath();
      for (let i = 0; i < base.length; i += 1) {
        const p = base[i];
        if (!p) continue;
        if (i === 0) ctx.moveTo(p.x, p.y);
        else ctx.lineTo(p.x, p.y);
      }
      ctx.closePath();
      ctx.strokeStyle = "rgba(200,255,0,0.18)";
      ctx.lineWidth = 6;
      ctx.stroke();
      ctx.strokeStyle = "rgba(200,255,0,0.9)";
      ctx.lineWidth = 1.5;
      ctx.stroke();
    };

    const frame = (now: number) => {
      raf = 0;
      if (now - lastRoll >= 1000 / 30) {
        lastRoll = now;
        frameIndex += 1;
        drawArc(0xe1ec + frameIndex);
      }
      if (!document.hidden) raf = requestAnimationFrame(frame);
    };

    const stop = () => {
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
    };
    const play = () => {
      if (!raf && !document.hidden && !reduced && inView) raf = requestAnimationFrame(frame);
    };
    const onVisibility = () => (document.hidden ? stop() : play());

    const resize = () => {
      const rect = root.getBoundingClientRect();
      const dpr = Math.min(2, window.devicePixelRatio || 1);
      width = rect.width;
      height = rect.height;
      canvas.width = Math.max(1, Math.round(width * dpr));
      canvas.height = Math.max(1, Math.round(height * dpr));
      canvas.style.width = `${width}px`;
      canvas.style.height = `${height}px`;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      if (reduced || !inView) drawSteady();
    };

    const observer = new ResizeObserver(resize);
    observer.observe(root);
    resize();
    play();
    document.addEventListener("visibilitychange", onVisibility);
    return () => {
      observer.disconnect();
      document.removeEventListener("visibilitychange", onVisibility);
      stop();
    };
  }, [inViewRef, reduced, inView, amp, cornerRadius]);

  return (
    <div
      ref={inViewRef}
      className={cn("relative isolate", className)}
      data-pax-asset="component-animations-electric-border"
    >
      <canvas
        ref={canvasRef}
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 z-[2]"
      />
      <div className="relative z-[1] p-[10px]">
        {children ?? (
          <div className="flex min-h-[220px] flex-col justify-center rounded-2xl bg-[var(--pax-night,#0b0d10)] p-8 text-[var(--pax-paper,#f6f2ea)]">
            <p className="m-0 text-xs font-medium uppercase tracking-[0.22em] text-[var(--pax-mute,#6b645c)]">
              Electric border
            </p>
            <h3 className="mt-3 max-w-md text-2xl font-semibold leading-snug tracking-tight [text-wrap:balance]">
              A live arc rides the edge of this card at thirty rolls per second.
            </h3>
            <p className="mt-3 max-w-sm text-sm leading-relaxed text-[var(--pax-mute,#6b645c)]">
              Turn the voltage prop up for a meaner storm.
            </p>
          </div>
        )}
      </div>
    </div>
  );
}