Border Electric Arc

CTAloud

An electric border for a CTA — the outline crackles like a live wire via feTurbulence displacement, micro-arcs spark off the corners, and near the cursor the stroke opens a gap that tracks the pointer with an arc jumping across it.

Install
npx shadcn add https://design.helpmarq.com/r/border-electric-arc.json
Source
registry/loud/border-electric-arc/component.tsx
"use client";

import { useEffect, useId, useRef } from "react";

// ---------------------------------------------------------------------------
// SparkGap — an electric border for a CTA. A rounded-rect outline crackles
// like a live wire: feTurbulence -> feDisplacementMap jitters a 1.5px
// --foreground stroke over a blurred --accent glow copy, with random
// micro-arcs sparking off the corners every 1-3s. Near the cursor the border
// OPENS a small gap at the pointer's nearest-edge projection and a short
// bright arc jumps across it, tracking the pointer along the perimeter.
// Pressing the button is a full discharge: a bright flash floods the stroke,
// then the border runs "spent" (calmer jitter, dim glow) for 2s before
// normal idle crackle resumes.
//
// Dash-gap math is done in RAW SVG user units against the path's real
// measured perimeter (getTotalLength on an explicit rounded-rect <path>).
// Deliberately NO pathLength attribute and NO vector-effect anywhere:
// combining pathLength with non-scaling-stroke makes Chromium compute the
// dash in screen space and the gap lands wrong while every attribute reads
// correct. This SVG is never CSS-scaled (width/height attrs = CSS pixels,
// no viewBox scaling), so raw-unit dasharray math is exact.
//
// Hot path is all direct DOM writes via refs — feTurbulence seed/frequency
// rewrites on a throttled 150ms interval (not per frame), dasharray/offset
// and the arc path on pointermove, opacity/stroke-width via short CSS
// transitions for flash/settle. No React state anywhere in the loop.
//
// A11y: the CTA is a real <button> with its accessible name intact; every
// effect layer is an aria-hidden, pointer-events-none SVG sibling so nothing
// ever intercepts clicks, hover or focus meant for the button. Idle work
// (jitter + micro-arcs) pauses entirely off-viewport via IntersectionObserver
// and while the document is hidden. prefers-reduced-motion: steady solid
// border (no displacement filter, no gap, no arcs), glow only as a plain
// CSS box-shadow on the button's :hover / :focus-visible.
// ---------------------------------------------------------------------------

export interface SparkGapProps {
  /** Accessible label / visible text of the CTA button. */
  label?: string;
  onClick?: () => void;
  className?: string;
}

const OUT = 5; // border sits this far outside the button box
const MARGIN = 14; // extra svg room for glow + corner arcs
const EXT = OUT + MARGIN;
const RADIUS = 10;
const STROKE_W = 1.5;
const GAP_PX = 16; // gap opened under the cursor
const NEAR_PX = 30; // pointer-to-border distance that opens the gap
const JITTER_MS = 150; // throttled turbulence rewrite cadence
const SPARK_MIN_MS = 1000;
const SPARK_MAX_MS = 3000;
const FLASH_MS = 110; // discharge flash duration
const SPENT_MS = 2000; // calm "spent" window after a discharge
const SCALE_IDLE = 2.8; // displacement scale, live wire
const SCALE_SPENT = 1.1; // displacement scale, spent wire

interface Layout {
  x: number;
  y: number;
  w: number;
  h: number;
  r: number;
  P: number; // measured perimeter (getTotalLength), raw user units
  k: number; // measured / analytic correction factor
}

function roundedRectPath(x: number, y: number, w: number, h: number, r: number): string {
  return (
    `M ${x + r} ${y} L ${x + w - r} ${y} A ${r} ${r} 0 0 1 ${x + w} ${y + r}` +
    ` L ${x + w} ${y + h - r} A ${r} ${r} 0 0 1 ${x + w - r} ${y + h}` +
    ` L ${x + r} ${y + h} A ${r} ${r} 0 0 1 ${x} ${y + h - r}` +
    ` L ${x} ${y + r} A ${r} ${r} 0 0 1 ${x + r} ${y} Z`
  );
}

// Nearest point on the perimeter, expressed as analytic arc-length from the
// path start (top-left, clockwise) plus the pointer's distance to it. The
// four straight edges are checked and the projection clamped to each; corner
// arcs are approximated as belonging to the nearer adjacent edge (clamping
// to a segment end IS that approximation — no exact arc math needed).
function projectToPerimeter(
  lx: number,
  ly: number,
  L: Layout
): { s: number; dist: number } {
  const { x, y, w, h, r } = L;
  const q = (Math.PI * r) / 2; // one corner arc, analytic
  const edges = [
    { ax: x + r, ay: y, bx: x + w - r, by: y, start: 0 },
    { ax: x + w, ay: y + r, bx: x + w, by: y + h - r, start: w - 2 * r + q },
    {
      ax: x + w - r,
      ay: y + h,
      bx: x + r,
      by: y + h,
      start: w - 2 * r + q + (h - 2 * r) + q,
    },
    {
      ax: x,
      ay: y + h - r,
      bx: x,
      by: y + r,
      start: 2 * (w - 2 * r) + (h - 2 * r) + 3 * q,
    },
  ];
  let bestS = 0;
  let bestDist = Infinity;
  for (const e of edges) {
    const abx = e.bx - e.ax;
    const aby = e.by - e.ay;
    const len2 = abx * abx + aby * aby;
    const t =
      len2 > 0
        ? Math.max(0, Math.min(1, ((lx - e.ax) * abx + (ly - e.ay) * aby) / len2))
        : 0;
    const px = e.ax + abx * t;
    const py = e.ay + aby * t;
    const d = Math.hypot(lx - px, ly - py);
    if (d < bestDist) {
      bestDist = d;
      bestS = e.start + t * Math.sqrt(len2);
    }
  }
  return { s: bestS, dist: bestDist };
}

export function SparkGap({
  label = "Get Started",
  onClick,
  className = "",
}: SparkGapProps) {
  const uid = useId().replace(/:/g, "");
  const filterId = `ns-sg-jitter-${uid}`;

  const wrapRef = useRef<HTMLDivElement>(null);
  const svgRef = useRef<SVGSVGElement>(null);
  const glowRef = useRef<SVGPathElement>(null);
  const coreRef = useRef<SVGPathElement>(null);
  const arcRef = useRef<SVGPathElement>(null);
  const sparkARef = useRef<SVGPathElement>(null);
  const sparkBRef = useRef<SVGPathElement>(null);
  const turbRef = useRef<SVGFETurbulenceElement>(null);
  const dispRef = useRef<SVGFEDisplacementMapElement>(null);
  const engineRef = useRef<{ discharge: () => void } | null>(null);

  useEffect(() => {
    const wrap = wrapRef.current;
    const svg = svgRef.current;
    const glow = glowRef.current;
    const core = coreRef.current;
    const arc = arcRef.current;
    const sparkA = sparkARef.current;
    const sparkB = sparkBRef.current;
    const turb = turbRef.current;
    const disp = dispRef.current;
    if (!wrap || !svg || !glow || !core || !arc || !sparkA || !sparkB || !turb || !disp)
      return;

    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    let reduced = mq.matches;

    // -- hot-path state: locals + refs only, never React state --------------
    let layout: Layout | null = null;
    let visible = true;
    let mode: "idle" | "spent" = "idle";
    let near = false; // pointer within NEAR_PX of the border
    let gapCur = 0;
    let gapTarget = 0;
    let sPath = 0; // gap center, measured units along the path
    let seed = 7;
    let skipTick = false;
    let sparkFork = false;
    let tweenRaf = 0;
    let tweenLast = 0;
    let jitterTimer: number | undefined;
    let sparkTimer: number | undefined;
    let flashT1: number | undefined;
    let flashT2: number | undefined;

    const applyGlow = () => {
      if (mode === "spent") {
        glow.style.opacity = "0.12";
        return;
      }
      glow.style.opacity = near ? "0.55" : "0.3";
    };

    // Dash window: pattern is [dash P-g, gap g]; offsetting by P - s - g/2
    // centers the gap at path distance s. All raw measured units — no
    // pathLength, no vector-effect (see header comment for why).
    const applyGap = () => {
      if (!layout) return;
      const { P } = layout;
      if (gapCur < 0.6) {
        core.removeAttribute("stroke-dasharray");
        core.removeAttribute("stroke-dashoffset");
        glow.removeAttribute("stroke-dasharray");
        glow.removeAttribute("stroke-dashoffset");
        arc.style.opacity = "0";
        return;
      }
      const g = gapCur;
      const dash = `${(P - g).toFixed(2)} ${g.toFixed(2)}`;
      const off = (P - sPath - g / 2).toFixed(2);
      core.setAttribute("stroke-dasharray", dash);
      core.setAttribute("stroke-dashoffset", off);
      glow.setAttribute("stroke-dasharray", dash);
      glow.setAttribute("stroke-dashoffset", off);
      // the arc that jumps the gap: jagged 4-point path between the gap lips
      const a = core.getPointAtLength((((sPath - g / 2) % P) + P) % P);
      const b = core.getPointAtLength((sPath + g / 2) % P);
      const dx = b.x - a.x;
      const dy = b.y - a.y;
      const len = Math.hypot(dx, dy) || 1;
      const nx = -dy / len; // unit normal for the zigzag
      const ny = dx / len;
      const j1 = (Math.random() - 0.5) * 5;
      const j2 = (Math.random() - 0.5) * 5;
      arc.setAttribute(
        "d",
        `M ${a.x.toFixed(1)} ${a.y.toFixed(1)}` +
          ` L ${(a.x + dx * 0.33 + nx * j1).toFixed(1)} ${(a.y + dy * 0.33 + ny * j1).toFixed(1)}` +
          ` L ${(a.x + dx * 0.66 + nx * j2).toFixed(1)} ${(a.y + dy * 0.66 + ny * j2).toFixed(1)}` +
          ` L ${b.x.toFixed(1)} ${b.y.toFixed(1)}`
      );
      arc.style.opacity = Math.min(1, g / GAP_PX).toFixed(2);
    };

    // short rAF tween only while the gap is opening/closing — not a
    // permanent loop; pointer position writes happen in the move handler
    const tweenStep = (t: number) => {
      tweenRaf = 0;
      const dt = tweenLast ? Math.min(0.05, (t - tweenLast) / 1000) : 1 / 60;
      tweenLast = t;
      gapCur += (gapTarget - gapCur) * Math.min(1, dt * 16);
      if (Math.abs(gapTarget - gapCur) < 0.4) gapCur = gapTarget;
      applyGap();
      if (gapCur !== gapTarget) tweenRaf = requestAnimationFrame(tweenStep);
    };
    const startTween = () => {
      if (!tweenRaf && gapCur !== gapTarget) {
        tweenLast = 0;
        tweenRaf = requestAnimationFrame(tweenStep);
      }
    };

    const onMove = (e: MouseEvent) => {
      if (reduced || !layout) return;
      const rect = wrap.getBoundingClientRect();
      if (rect.width < 4) return;
      // svg's top-left sits at (rect.left - EXT, rect.top - EXT)
      const lx = e.clientX - rect.left + EXT;
      const ly = e.clientY - rect.top + EXT;
      const proj = projectToPerimeter(lx, ly, layout);
      const nowNear = proj.dist < NEAR_PX;
      if (nowNear !== near) {
        near = nowNear;
        applyGlow();
      }
      sPath = proj.s * layout.k;
      gapTarget = nowNear ? GAP_PX : 0;
      if (gapCur > 0.6) applyGap(); // gap follows the cursor instantly
      startTween();
    };

    // -- throttled crackle: rewrite turbulence attrs every 150ms, not/frame --
    const jitterTick = () => {
      if (!visible || document.hidden) return;
      skipTick = !skipTick;
      if (mode === "spent" && skipTick) return; // spent wire ticks half rate
      seed = (seed + 1) % 997;
      turb.setAttribute("seed", String(seed));
      turb.setAttribute(
        "baseFrequency",
        `0.02 ${(0.05 + Math.random() * 0.04).toFixed(3)}`
      );
      disp.setAttribute("scale", String(mode === "spent" ? SCALE_SPENT : SCALE_IDLE));
    };
    const startJitter = () => {
      if (jitterTimer === undefined && !reduced)
        jitterTimer = window.setInterval(jitterTick, JITTER_MS);
    };
    const stopJitter = () => {
      if (jitterTimer !== undefined) {
        window.clearInterval(jitterTimer);
        jitterTimer = undefined;
      }
    };

    // -- micro-arcs: recursive setTimeout re-rolling 1-3s each round ---------
    const flashPath = (el: SVGPathElement, d: string) => {
      el.setAttribute("d", d);
      el.style.transition = "none";
      el.style.opacity = "0.95";
      requestAnimationFrame(() => {
        el.style.transition = "opacity 220ms ease-out";
        el.style.opacity = "0";
      });
    };
    const jaggedBranch = (cx: number, cy: number, nx: number, ny: number): string => {
      let px = cx;
      let py = cy;
      let d = `M ${px.toFixed(1)} ${py.toFixed(1)}`;
      for (let i = 0; i < 3; i++) {
        const step = 3 + Math.random() * 4;
        const jit = (Math.random() - 0.5) * 5;
        px += nx * step - ny * jit;
        py += ny * step + nx * jit;
        d += ` L ${px.toFixed(1)} ${py.toFixed(1)}`;
      }
      return d;
    };
    const fireSpark = () => {
      if (!layout || !visible || document.hidden || reduced || mode === "spent") return;
      const { x, y, w, h, r } = layout;
      const inset = r * 0.3;
      const corners = [
        { cx: x + inset, cy: y + inset, dx: -1, dy: -1 },
        { cx: x + w - inset, cy: y + inset, dx: 1, dy: -1 },
        { cx: x + w - inset, cy: y + h - inset, dx: 1, dy: 1 },
        { cx: x + inset, cy: y + h - inset, dx: -1, dy: 1 },
      ];
      const c = corners[Math.floor(Math.random() * corners.length)];
      if (!c) return;
      const nx = c.dx / Math.SQRT2;
      const ny = c.dy / Math.SQRT2;
      flashPath(sparkA, jaggedBranch(c.cx, c.cy, nx, ny));
      sparkFork = Math.random() < 0.4;
      if (sparkFork) {
        // second branch forks off at a steeper angle from the same corner
        const rot = (Math.random() < 0.5 ? 1 : -1) * 0.7;
        const fx = nx * Math.cos(rot) - ny * Math.sin(rot);
        const fy = nx * Math.sin(rot) + ny * Math.cos(rot);
        flashPath(sparkB, jaggedBranch(c.cx, c.cy, fx, fy));
      }
    };
    const scheduleSpark = () => {
      sparkTimer = window.setTimeout(() => {
        fireSpark();
        scheduleSpark();
      }, SPARK_MIN_MS + Math.random() * (SPARK_MAX_MS - SPARK_MIN_MS));
    };
    const startSparks = () => {
      if (sparkTimer === undefined && !reduced) scheduleSpark();
    };
    const stopSparks = () => {
      if (sparkTimer !== undefined) {
        window.clearTimeout(sparkTimer);
        sparkTimer = undefined;
      }
    };

    // -- discharge: flash flood, then 2s spent, then idle resumes ------------
    const discharge = () => {
      if (reduced) return;
      window.clearTimeout(flashT1);
      window.clearTimeout(flashT2);
      mode = "idle";
      const fast =
        "opacity 80ms ease-out, stroke-width 80ms ease-out, filter 80ms ease-out";
      core.style.transition = fast;
      core.style.opacity = "1";
      core.style.strokeWidth = "2.6";
      glow.style.transition = fast;
      glow.style.opacity = "1";
      glow.style.filter = "blur(6px)";
      flashT1 = window.setTimeout(() => {
        mode = "spent";
        disp.setAttribute("scale", String(SCALE_SPENT));
        const settle =
          "opacity 260ms ease-in, stroke-width 260ms ease-in, filter 260ms ease-in";
        core.style.transition = settle;
        core.style.opacity = "0.65";
        core.style.strokeWidth = String(STROKE_W);
        glow.style.transition = settle;
        glow.style.filter = "blur(3.5px)";
        applyGlow();
        flashT2 = window.setTimeout(() => {
          mode = "idle";
          disp.setAttribute("scale", String(SCALE_IDLE));
          core.style.transition = "opacity 300ms ease-out";
          core.style.opacity = "1";
          glow.style.transition = "opacity 300ms ease-out";
          applyGlow();
        }, SPENT_MS);
      }, FLASH_MS);
    };
    engineRef.current = { discharge };

    // -- sizing: real pixel geometry, measured perimeter ---------------------
    const resize = () => {
      const rect = wrap.getBoundingClientRect();
      if (rect.width < 4 || rect.height < 4) return;
      const bw = rect.width + OUT * 2;
      const bh = rect.height + OUT * 2;
      const sw = bw + MARGIN * 2;
      const sh = bh + MARGIN * 2;
      svg.setAttribute("width", String(sw));
      svg.setAttribute("height", String(sh));
      const r = Math.min(RADIUS, bw / 2, bh / 2);
      const d = roundedRectPath(MARGIN, MARGIN, bw, bh, r);
      glow.setAttribute("d", d);
      core.setAttribute("d", d);
      const P = core.getTotalLength();
      const analytic = 2 * (bw - 2 * r) + 2 * (bh - 2 * r) + 2 * Math.PI * r;
      layout = { x: MARGIN, y: MARGIN, w: bw, h: bh, r, P, k: P / analytic };
      applyGap();
    };

    const applyReduced = () => {
      if (reduced) {
        stopJitter();
        stopSparks();
        window.clearTimeout(flashT1);
        window.clearTimeout(flashT2);
        if (tweenRaf) cancelAnimationFrame(tweenRaf);
        tweenRaf = 0;
        gapCur = gapTarget = 0;
        mode = "idle";
        near = false;
        applyGap();
        // steady solid border: crisp stroke, no displacement filter at all
        core.removeAttribute("filter");
        core.style.transition = "none";
        core.style.opacity = "1";
        core.style.strokeWidth = String(STROKE_W);
        arc.style.opacity = "0";
        sparkA.style.opacity = "0";
        sparkB.style.opacity = "0";
        // glow layer off; hover/focus glow comes from CSS box-shadow instead
      } else {
        core.setAttribute("filter", `url(#${filterId})`);
        applyGlow();
        if (visible) {
          startJitter();
          startSparks();
        }
      }
    };
    const onMq = () => {
      reduced = mq.matches;
      applyReduced();
    };
    mq.addEventListener("change", onMq);

    // -- observers -----------------------------------------------------------
    const ro = new ResizeObserver(resize);
    ro.observe(wrap);
    resize();

    const io = new IntersectionObserver((entries) => {
      visible = entries[0]?.isIntersecting ?? true;
      if (visible && !reduced) {
        startJitter();
        startSparks();
      } else {
        stopJitter();
        stopSparks();
      }
    });
    io.observe(wrap);

    const onVis = () => {
      // interval ticks already no-op while hidden; kick a tick on return
      if (!document.hidden && !reduced && visible) jitterTick();
    };
    document.addEventListener("visibilitychange", onVis);

    window.addEventListener("pointermove", onMove);
    window.addEventListener("mousemove", onMove); // synthetic MouseEvents too

    applyReduced();
    if (!reduced && visible) {
      startJitter();
      startSparks();
    }

    return () => {
      stopJitter();
      stopSparks();
      window.clearTimeout(flashT1);
      window.clearTimeout(flashT2);
      if (tweenRaf) cancelAnimationFrame(tweenRaf);
      mq.removeEventListener("change", onMq);
      ro.disconnect();
      io.disconnect();
      document.removeEventListener("visibilitychange", onVis);
      window.removeEventListener("pointermove", onMove);
      window.removeEventListener("mousemove", onMove);
      engineRef.current = null;
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <div ref={wrapRef} className={`ns-sg relative inline-flex ${className}`}>
      <style>{CSS}</style>
      {/* Effect overlay: aria-hidden + pointer-events-none sibling, extends
          EXT px past the wrapper so glow and corner arcs never clip. */}
      <svg
        ref={svgRef}
        width={0}
        height={0}
        aria-hidden="true"
        focusable="false"
        className="pointer-events-none absolute"
        style={{ left: -EXT, top: -EXT, overflow: "visible" }}
      >
        <defs>
          <filter id={filterId} x="-10%" y="-10%" width="120%" height="120%">
            <feTurbulence
              ref={turbRef}
              type="fractalNoise"
              baseFrequency="0.02 0.06"
              numOctaves={2}
              seed={7}
              result="n"
            />
            <feDisplacementMap
              ref={dispRef}
              in="SourceGraphic"
              in2="n"
              scale={SCALE_IDLE}
              xChannelSelector="R"
              yChannelSelector="G"
            />
          </filter>
        </defs>
        <path
          ref={glowRef}
          className="ns-sg-glow"
          fill="none"
          stroke="var(--accent)"
          strokeWidth={5}
          strokeLinecap="round"
          style={{
            opacity: 0.3,
            filter: "blur(3.5px)",
            transition: "opacity 200ms ease-out",
          }}
        />
        <path
          ref={coreRef}
          fill="none"
          stroke="var(--foreground)"
          strokeWidth={STROKE_W}
          strokeLinecap="round"
          filter={`url(#${filterId})`}
        />
        <path
          ref={arcRef}
          className="ns-sg-arc"
          fill="none"
          stroke="var(--foreground)"
          strokeWidth={1.6}
          strokeLinecap="round"
          strokeLinejoin="round"
          style={{
            opacity: 0,
            filter:
              "drop-shadow(0 0 4px var(--accent)) drop-shadow(0 0 1.5px var(--accent))",
          }}
        />
        <path
          ref={sparkARef}
          className="ns-sg-spark"
          fill="none"
          stroke="var(--accent)"
          strokeWidth={1.3}
          strokeLinecap="round"
          strokeLinejoin="round"
          style={{ opacity: 0, filter: "drop-shadow(0 0 3px var(--accent))" }}
        />
        <path
          ref={sparkBRef}
          className="ns-sg-spark"
          fill="none"
          stroke="var(--accent)"
          strokeWidth={1.1}
          strokeLinecap="round"
          strokeLinejoin="round"
          style={{ opacity: 0, filter: "drop-shadow(0 0 3px var(--accent))" }}
        />
      </svg>

      <button
        type="button"
        onPointerDown={() => engineRef.current?.discharge()}
        onClick={(e) => {
          // keyboard activation (Enter/Space) arrives as click with detail 0
          if (e.detail === 0) engineRef.current?.discharge();
          onClick?.();
        }}
        className="ns-sg-btn relative z-[1] rounded-[6px] bg-background px-6 py-2.5 text-sm font-medium text-foreground transition-colors hover:bg-foreground/[0.08] active:bg-foreground/[0.14] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
      >
        {label}
      </button>
    </div>
  );
}

const CSS = `
@media (prefers-reduced-motion: reduce){
  .ns-sg-glow,.ns-sg-arc,.ns-sg-spark{opacity:0 !important;transition:none !important;}
  .ns-sg-btn:hover,.ns-sg-btn:focus-visible{
    box-shadow:0 0 14px 0 color-mix(in srgb, var(--accent) 45%, transparent);
  }
}
`;
Use when

An electric border for a CTA — the outline crackles like a live wire via feTurbulence displacement, micro-arcs spark off the corners, and near the cursor the stroke opens a gap that tracks the pointer with an arc jumping across it. Pressing discharges the wire: a bright flash, then two seconds spent and calm.

Build spec

Build an electric-border CTA where a real <button> (accessible name intact, default label 'Get Started') sits inside a position:relative wrapper and every effect layer is an absolutely-positioned aria-hidden pointer-events-none SVG sibling — nothing ever intercepts a click, hover or Tab meant for the button, and the button keeps its own hover fill and focus-visible outline. The SVG extends ~19px past the wrapper on all sides (5px standoff for the border rect plus 14px of glow/arc headroom) and is sized in raw CSS pixels by a ResizeObserver: width/height attributes equal to the on-screen size, no viewBox scaling, no CSS transform — that is what makes raw-unit dash math exact. The border is an explicit rounded-rect <path> (r=10) stroked twice: a 5px --accent copy blurred 3.5px underneath at 0.3 idle opacity (0.55 when the pointer is near, 0.12 when spent) for the electric glow, and a crisp 1.5px --foreground core on top carrying the crackle. The crackle is an SVG filter — feTurbulence type=fractalNoise numOctaves=2 into feDisplacementMap scale 2.8 — whose jitter is animated by rewriting the feTurbulence element's seed and baseFrequency attributes directly via refs on a throttled 150ms setInterval, never per frame and never through React state; ticks no-op while document.hidden. CRITICAL dash trap: do NOT set pathLength on the element and do NOT use vector-effect:non-scaling-stroke anywhere — combining them makes Chromium compute the dash window in screen space so it lands wrong while every attribute reads correct. Instead measure the real perimeter P with getTotalLength() on the path and compute everything in raw user units. Gap-follows-cursor: a window pointermove/mousemove listener (so synthetic MouseEvents drive the same path) converts the pointer to SVG coords, projects it onto the nearest of the four straight edges (clamped dot-product projection per edge, corners approximated as the nearer adjacent edge — clamping to a segment end IS that approximation), converts the analytic arc-length to measured units via a k = P/analytic correction, and when the pointer is within 30px of the border opens a 16px gap centered there: stroke-dasharray '(P-g) g' with stroke-dashoffset P - s - g/2 written to both stroke layers. A short rAF tween (only alive while opening/closing, ~150ms feel) eases the gap width; position updates ride the move events directly. A separate 4-point jagged <path> in --foreground with a double --accent drop-shadow spans exactly the gap lips (endpoints from getPointAtLength at s±g/2, two mid vertices jittered ±2.5px along the normal, re-jittered every move so it crackles) — the arc that jumps the gap and follows the cursor along the edge. Pointer leaves the proximity band: gap target 0, dash attributes removed once under 0.6px. Micro-arcs: a recursive setTimeout re-rolling a 1-3s delay each round picks a random corner and flashes a 3-segment jagged branch (3-7px steps outward along the corner diagonal, ±2.5px perpendicular jitter) on one of two pooled paths — opacity snapped to 0.95 with transition:none, then eased to 0 over 220ms on the next frame; 40% of strikes fork a second shorter branch rotated ~0.7rad on the other pooled path. Press = discharge: on the button's pointerdown (and keyboard activation, detected as click with detail 0) the whole stroke floods — core to opacity 1 / 2.6px width, glow to opacity 1 / blur 6px over an 80ms ease-out — then settles over 260ms into a 'spent' state held for exactly 2000ms: core at 0.65 opacity, glow at 0.12, displacement scale dropped to 1.1 and every other jitter tick skipped, micro-arcs suppressed; then idle parameters restore over 300ms. An IntersectionObserver on the wrapper stops the jitter interval and the micro-arc timeout entirely off-viewport and restarts them on re-entry. prefers-reduced-motion (checked via matchMedia with a live change listener): the displacement filter attribute is removed so the border renders as a steady solid stroke, no gap, no arcs, no discharge animation, and the glow appears only as a plain CSS box-shadow (color-mix of --accent) on the button's :hover/:focus-visible, enforced belt-and-braces by a media-query style block zeroing the effect layers. All color from theme tokens — --foreground core and arc, --accent glow — as live var() references in the SVG so both themes render without any JS re-derivation. Zero dependencies, no canvas.

Tags
ctabuttonborderelectricsvgfeTurbulencecursorhoverglow