Workspace / component / animations
Electric Border
paxfx add blocks/component-animations-electric-borderlocal onlyTier: 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>
);
}