---
title: Electric Border
description: A card edge that crackles like electricity.
category: Cards
tier: free
url: https://ui.dimaac.com/components/electric-border
registry: https://ui.dimaac.com/r/electric-border.json
---

# Electric Border

A card whose border crackles like a live wire. A thin coloured line is torn up by an SVG displacement filter fed with two pairs of sliding turbulence noise that loop without a seam, and soft copies of the edge, a white sheen and a wide aura make it glow. Hover it (or focus something inside it) and a wash of colour charges the card. Pure SVG filters and CSS, no canvas and no libraries: any CSS colour works, every card gets its own filter, the noise stops moving off screen, and with reduced motion the crackle holds still.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/electric-border.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/electric-border.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/electric-border.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/electric-border`

Dependencies:

None: it only needs React 19.

By hand:

1. It needs nothing but React 19.
2. Copy the component file below into components/dimaac/electric-border.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { ElectricBorder } from "@/components/dimaac/electric-border" and give its parent a size: it fills it.

## Usage

```tsx
import { ElectricBorder } from "@/components/dimaac/electric-border";

<ElectricBorder />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `color` | `string` | `"#9a71f5"` | The colour of the wire and its glow. Any CSS colour (the default is oklch(0.65 0.19 295)). |
| `speed` | `number` | `1` | How fast the crackle travels. 1 is one lap of the noise every 6 seconds. |
| `chaos` | `number` | `0.5` | How far the noise throws the wire off its line. 0 is a clean edge. |
| `thickness` | `number` | `2` | Width of the wire and its glows, in pixels. |
| `radius` | `number` | `24` | Corner radius, in pixels. |
| `glow` | `number` | `0.6` | How strong the glow, sheen and aura are. 0 leaves only the wire. |
| `charged` | `boolean` | `false` | Keeps the card lit up as if it were hovered (it also lights up on hover and keyboard focus inside it). |
| `surface` | `string` | - | Background of the card. Defaults to a near-black tinted with the colour. |
| `className` | `string` | `""` | Extra classes for the card. Give it a size, or let its content size it. |
| `children` | `ReactNode` | - | The card's content. |

## Source

### electric-border.tsx → `components/dimaac/electric-border.tsx`

```tsx
"use client";

import { useEffect, useId, useRef, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from "react";

// A card whose edge crackles like electricity. A thin coloured line (the "wire") is pushed around
// by an SVG displacement filter fed with moving turbulence noise, and a few soft copies of the edge
// (sharp, blurred, a white sheen and a wide aura) sit around it so the line seems to glow.
//
// How the noise moves: the same noise picture is drawn twice, one copy sliding in from the top
// while the other slides out at the bottom. Their seam travels down the box and after one lap the
// picture is back where it started, so the loop has no jump. A second pair does the same sideways.
// We move them from requestAnimationFrame (not SMIL) so speed can change without a jump, and so
// the loop stops off screen and for reduced motion.

export type ElectricBorderProps = {
  /** The colour of the wire and its glow. Any CSS colour (the default is oklch(0.65 0.19 295)). */
  color?: string;
  /** How fast the crackle travels. 1 is one lap of the noise every 6 seconds. */
  speed?: number;
  /** How far the noise throws the wire off its line. 0 is a clean edge. */
  chaos?: number;
  /** Width of the wire and its glows, in pixels. */
  thickness?: number;
  /** Corner radius, in pixels. */
  radius?: number;
  /** How strong the glow, sheen and aura are. 0 leaves only the wire. */
  glow?: number;
  /** Keeps the card lit up as if it were hovered (it also lights up on hover and keyboard focus inside it). */
  charged?: boolean;
  /** Background of the card. Defaults to a near-black tinted with the colour. */
  surface?: string;
  /** Extra classes for the card. Give it a size, or let its content size it. */
  className?: string;
  /** The card's content. */
  children?: ReactNode;
};

// prefers-reduced-motion, live
function subscribeToMotion(onChange: () => void) {
  const query = window.matchMedia("(prefers-reduced-motion: reduce)");
  query.addEventListener("change", onChange);
  return () => query.removeEventListener("change", onChange);
}
const prefersReducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;

// One lap of the noise at speed 1, in seconds (the timing of the original build)
const LAP = 6;
// Room around the wire for the filter to draw into, so a thrown-out spark isn't cut off
const PAD = 80;
// The frame of the loop shown, still, when the visitor prefers reduced motion
const STILL = 0.35;

export function ElectricBorder({
  color = "#9a71f5",
  speed = 1,
  chaos = 0.5,
  thickness = 2,
  radius = 24,
  glow = 0.6,
  charged = false,
  surface,
  className = "",
  children,
}: ElectricBorderProps) {
  // useId can hold characters that break url(#…), so keep letters, digits, - and _ only
  const filterId = `electric-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
  const wireRef = useRef<HTMLDivElement>(null);
  const downRef = useRef<SVGFEOffsetElement>(null);
  const upRef = useRef<SVGFEOffsetElement>(null);
  const rightRef = useRef<SVGFEOffsetElement>(null);
  const leftRef = useRef<SVGFEOffsetElement>(null);
  const reducedMotion = useSyncExternalStore(subscribeToMotion, prefersReducedMotion, () => false);

  // The wire's size. The noise has to slide exactly one filter area per lap to loop cleanly.
  const [size, setSize] = useState({ width: 350, height: 500 });
  const sizeRef = useRef(size);
  // Read every frame, so a new speed never restarts the loop
  const speedRef = useRef(speed);
  useEffect(() => {
    speedRef.current = speed;
  }, [speed]);

  useEffect(() => {
    const wire = wireRef.current;
    if (!wire) return;
    const observer = new ResizeObserver(() => {
      const next = { width: Math.max(1, Math.round(wire.offsetWidth)), height: Math.max(1, Math.round(wire.offsetHeight)) };
      if (next.width === sizeRef.current.width && next.height === sizeRef.current.height) return;
      sizeRef.current = next;
      setSize(next);
    });
    observer.observe(wire);
    return () => observer.disconnect();
  }, []);

  useEffect(() => {
    // Reduced motion: the still frame written in the markup below, no movement
    if (reducedMotion) return;
    const wire = wireRef.current;
    const offsets = [downRef.current, upRef.current, rightRef.current, leftRef.current];
    if (!wire || offsets.some((offset) => !offset)) return;
    const [down, up, right, left] = offsets as SVGFEOffsetElement[];

    // progress 0 → 1 is one lap. The pairs always cover the whole area between them.
    function place(progress: number) {
      const areaHeight = sizeRef.current.height + PAD * 2;
      const areaWidth = sizeRef.current.width + PAD * 2;
      down!.setAttribute("dy", String(areaHeight * (1 - progress)));
      up!.setAttribute("dy", String(-areaHeight * progress));
      right!.setAttribute("dx", String(areaWidth * (1 - progress)));
      left!.setAttribute("dx", String(-areaWidth * progress));
    }

    let frame = 0;
    let visible = true;
    let progress = 0;
    let last = performance.now();

    function tick(now: number) {
      frame = requestAnimationFrame(tick);
      // A long gap (a background tab) shouldn't make the noise leap
      const seconds = Math.min((now - last) / 1000, 0.1);
      last = now;
      if (!visible) return;
      progress = (progress + (seconds * speedRef.current) / LAP) % 1;
      place(progress);
    }

    // Nothing to move while the card is off screen (each frame re-renders the noise, which costs)
    const visibility = new IntersectionObserver(([entry]) => {
      visible = entry?.isIntersecting ?? true;
    });
    visibility.observe(wire);
    frame = requestAnimationFrame(tick);

    return () => {
      cancelAnimationFrame(frame);
      visibility.disconnect();
    };
  }, [reducedMotion]);

  // The glow numbers are the original's at glow 0.6, scaled up or down from there
  const strength = glow / 0.6;
  const radiusPx = `${radius}px`;
  const ring: CSSProperties = { borderRadius: radiusPx, borderWidth: thickness, borderStyle: "solid" };
  const sheen = "linear-gradient(-30deg, white, transparent 30%, transparent 70%, white)";
  const areaHeight = size.height + PAD * 2;
  const areaWidth = size.width + PAD * 2;
  // Where the noise starts (the loop moves it from here). With reduced motion it stays at this still frame.
  const start = reducedMotion ? STILL : 0;

  return (
    <div
      className={`group/electric relative ${className}`}
      style={
        {
          "--electric": color,
          // Lighter and softer versions of the colour, worked out by the browser so any colour works
          "--electric-light": "oklch(from var(--electric) calc(l + 0.06) calc(c * 0.75) h)",
          "--electric-muted": "oklch(from var(--electric) l c h / 0.45)",
          "--electric-soft": "oklch(from var(--electric) l c h / 0.55)",
          "--electric-tint": "oklch(from var(--electric) 0.3 calc(c / 2) h / 0.4)",
          padding: thickness,
          borderRadius: radiusPx,
          color: "oklch(0.985 0 0)",
        } as CSSProperties
      }
    >
      <svg aria-hidden="true" focusable="false" width="0" height="0" className="pointer-events-none absolute">
        <defs>
          <filter id={filterId} filterUnits="userSpaceOnUse" x={-PAD} y={-PAD} width={areaWidth} height={areaHeight} colorInterpolationFilters="linearRGB">
            {/* Two noise pictures, each used by a pair of offsets: one pair slides down, one sideways.
                stitchTiles makes each picture wrap around, so the seam where a pair meets doesn't show.
                Six octaves: the original's ten only add detail finer than a pixel, at a real cost per frame. */}
            <feTurbulence type="turbulence" baseFrequency="0.02" numOctaves={6} seed={1} stitchTiles="stitch" result="noiseA" />
            <feTurbulence type="turbulence" baseFrequency="0.02" numOctaves={6} seed={2} stitchTiles="stitch" result="noiseB" />
            <feOffset ref={downRef} in="noiseA" dx="0" dy={areaHeight * (1 - start)} result="down" />
            <feOffset ref={upRef} in="noiseA" dx="0" dy={-areaHeight * start} result="up" />
            <feOffset ref={rightRef} in="noiseB" dx={areaWidth * (1 - start)} dy="0" result="right" />
            <feOffset ref={leftRef} in="noiseB" dx={-areaWidth * start} dy="0" result="left" />
            <feComposite in="down" in2="up" result="vertical" />
            <feComposite in="right" in2="left" result="horizontal" />
            <feBlend in="vertical" in2="horizontal" mode="color-dodge" result="noise" />
            {/* chaos 0.5 is the original's scale of 50 */}
            <feDisplacementMap in="SourceGraphic" in2="noise" scale={chaos * 100} />
          </filter>
        </defs>
      </svg>

      {/* The wide coloured aura behind the card */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0"
        style={{
          borderRadius: radiusPx,
          filter: "blur(32px)",
          transform: "scale(1.1)",
          opacity: Math.min(0.3 * strength, 0.6),
          background: "linear-gradient(-30deg, var(--electric-light), transparent, var(--electric))",
        }}
      />

      {/* The card itself: a faint diagonal tint of the colour over a dark surface */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0"
        style={{
          borderRadius: radiusPx,
          background: `linear-gradient(-30deg, var(--electric-tint), transparent, var(--electric-tint)), ${surface ?? "oklch(from var(--electric) 0.17 0.02 h)"}`,
        }}
      />

      {/* The edge: a faint steady line with the crackling wire inside it, then two glowing copies */}
      <div aria-hidden="true" className="pointer-events-none absolute" style={{ inset: thickness }}>
        <div className="absolute inset-0" style={{ ...ring, borderColor: "var(--electric-muted)" }}>
          <div ref={wireRef} className="relative h-full w-full overflow-hidden" style={{ ...ring, borderColor: "var(--electric)", filter: `url(#${filterId})` }}>
            {/* The charge: a wash of colour that fills the card on hover or keyboard focus, torn by the same noise.
                (Only :focus-visible, so a tapped button doesn't keep it lit after a second tap.) */}
            <div
              className={`absolute inset-0 transition-opacity duration-[650ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-hover/electric:opacity-100 group-has-[:focus-visible]/electric:opacity-100 ${charged ? "opacity-100" : "opacity-0"}`}
              style={{ background: "linear-gradient(to bottom, var(--electric), transparent)" }}
            />
          </div>
        </div>
        <div className="absolute inset-0" style={{ ...ring, borderColor: "var(--electric-soft)", filter: "blur(1px)", opacity: Math.min(strength, 1) }} />
        <div className="absolute inset-0" style={{ ...ring, borderColor: "var(--electric-light)", filter: `blur(${4 * Math.max(strength, 0.5)}px)`, opacity: Math.min(strength, 1) }} />
      </div>

      {/* A white sheen on two corners, blended into what's under it (twice, the second one softer) */}
      {[1, 0.5].map((amount) => (
        <div
          key={amount}
          aria-hidden="true"
          className="pointer-events-none absolute inset-0"
          style={{ borderRadius: radiusPx, mixBlendMode: "overlay", transform: "scale(1.1)", filter: "blur(16px)", background: sheen, opacity: Math.min(amount * strength, 1) }}
        />
      ))}

      <div className="relative h-full w-full">{children}</div>
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { useState } from "react";
import { ElectricBorder, type ElectricBorderProps } from "@/components/dimaac/electric-border";

// The card from the video: a dark "Neon Pulse" card with a crackling edge that fills with colour
// when you hover it. Here the card is also a real button, so a tap or Enter charges it too.

export default function Demo(props: Partial<ElectricBorderProps>) {
  const [on, setOn] = useState(false);

  return (
    <div
      className="relative flex h-full w-full items-center justify-center overflow-hidden bg-[oklch(0.965_0.008_295)] text-[oklch(0.22_0.03_285)] dark:bg-[oklch(0.11_0.025_285)] dark:text-[oklch(0.92_0.01_285)]"
      style={{ fontFamily: '"Bricolage Grotesque", ui-sans-serif, system-ui, sans-serif' }}
    >
      {/* A faint pool of colour behind the card, so the edge has something to glow against */}
      <div aria-hidden="true" className="pointer-events-none absolute inset-0 bg-[radial-gradient(48%_52%_at_50%_50%,oklch(0.93_0.05_295/0.9),transparent)] dark:bg-[radial-gradient(48%_52%_at_50%_50%,oklch(0.24_0.07_290/0.7),transparent)]" />
      <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,500;12..96,600;12..96,700&display=swap" precedence="default" />

      <ElectricBorder {...props} charged={on || props.charged} className={`aspect-[358/508] h-[min(508px,calc(100dvh-110px))] max-w-[calc(100vw-48px)] ${props.className ?? ""}`}>
        <button
          type="button"
          aria-pressed={on}
          onClick={() => setOn((value) => !value)}
          className="flex h-full w-full cursor-pointer flex-col text-left outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-[10px] focus-visible:outline-[var(--electric)]"
          style={{ borderRadius: props.radius ?? 24 }}
        >
          <span className="flex flex-1 flex-col px-8 pt-8 pb-3 sm:px-9 sm:pt-9">
            <span className="relative w-fit rounded-[14px] border border-white/25 bg-white/[0.06] bg-[radial-gradient(47%_50%_at_50%_88%,rgba(255,255,255,0.12),transparent)] px-4 py-2 text-[14px] font-bold tracking-wide text-white/80 uppercase">
              Live wire
            </span>
            <span className="mt-auto text-[26px] leading-tight font-semibold tracking-[-0.02em]">Neon Pulse</span>
          </span>
          <span aria-hidden="true" className="block h-px w-full bg-current opacity-50 [mask-image:linear-gradient(to_right,transparent,black,transparent)]" />
          <span className="block px-8 pt-3 pb-8 text-[13px] leading-[1.55] opacity-55 sm:px-9 sm:pb-9">
            The border hums with energy. Hover or tap to charge it up.
          </span>
        </button>
      </ElectricBorder>
    </div>
  );
}
```

## Agent prompt

```text
Add the Electric Border component (<ElectricBorder />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/electric-border
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/electric-border.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/electric-border.json

Then use it like this:
import { ElectricBorder } from "@/components/dimaac/electric-border";
<ElectricBorder />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/electric-border.tsx.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```
