{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"electric-border","type":"registry:component","title":"Electric Border","description":"A card edge that crackles like electricity.","author":"DiMaac (HankTheTank) <https://dimaac.com>","dependencies":[],"files":[{"path":"registry/dimaac/electric-border/electric-border.tsx","type":"registry:component","target":"components/dimaac/electric-border.tsx","content":"\"use client\";\n\nimport { useEffect, useId, useRef, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from \"react\";\n\n// A card whose edge crackles like electricity. A thin coloured line (the \"wire\") is pushed around\n// by an SVG displacement filter fed with moving turbulence noise, and a few soft copies of the edge\n// (sharp, blurred, a white sheen and a wide aura) sit around it so the line seems to glow.\n//\n// How the noise moves: the same noise picture is drawn twice, one copy sliding in from the top\n// while the other slides out at the bottom. Their seam travels down the box and after one lap the\n// picture is back where it started, so the loop has no jump. A second pair does the same sideways.\n// We move them from requestAnimationFrame (not SMIL) so speed can change without a jump, and so\n// the loop stops off screen and for reduced motion.\n\nexport type ElectricBorderProps = {\n  /** The colour of the wire and its glow. Any CSS colour (the default is oklch(0.65 0.19 295)). */\n  color?: string;\n  /** How fast the crackle travels. 1 is one lap of the noise every 6 seconds. */\n  speed?: number;\n  /** How far the noise throws the wire off its line. 0 is a clean edge. */\n  chaos?: number;\n  /** Width of the wire and its glows, in pixels. */\n  thickness?: number;\n  /** Corner radius, in pixels. */\n  radius?: number;\n  /** How strong the glow, sheen and aura are. 0 leaves only the wire. */\n  glow?: number;\n  /** Keeps the card lit up as if it were hovered (it also lights up on hover and keyboard focus inside it). */\n  charged?: boolean;\n  /** Background of the card. Defaults to a near-black tinted with the colour. */\n  surface?: string;\n  /** Extra classes for the card. Give it a size, or let its content size it. */\n  className?: string;\n  /** The card's content. */\n  children?: ReactNode;\n};\n\n// prefers-reduced-motion, live\nfunction subscribeToMotion(onChange: () => void) {\n  const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n}\nconst prefersReducedMotion = () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n// One lap of the noise at speed 1, in seconds (the timing of the original build)\nconst LAP = 6;\n// Room around the wire for the filter to draw into, so a thrown-out spark isn't cut off\nconst PAD = 80;\n// The frame of the loop shown, still, when the visitor prefers reduced motion\nconst STILL = 0.35;\n\nexport function ElectricBorder({\n  color = \"#9a71f5\",\n  speed = 1,\n  chaos = 0.5,\n  thickness = 2,\n  radius = 24,\n  glow = 0.6,\n  charged = false,\n  surface,\n  className = \"\",\n  children,\n}: ElectricBorderProps) {\n  // useId can hold characters that break url(#…), so keep letters, digits, - and _ only\n  const filterId = `electric-${useId().replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n  const wireRef = useRef<HTMLDivElement>(null);\n  const downRef = useRef<SVGFEOffsetElement>(null);\n  const upRef = useRef<SVGFEOffsetElement>(null);\n  const rightRef = useRef<SVGFEOffsetElement>(null);\n  const leftRef = useRef<SVGFEOffsetElement>(null);\n  const reducedMotion = useSyncExternalStore(subscribeToMotion, prefersReducedMotion, () => false);\n\n  // The wire's size. The noise has to slide exactly one filter area per lap to loop cleanly.\n  const [size, setSize] = useState({ width: 350, height: 500 });\n  const sizeRef = useRef(size);\n  // Read every frame, so a new speed never restarts the loop\n  const speedRef = useRef(speed);\n  useEffect(() => {\n    speedRef.current = speed;\n  }, [speed]);\n\n  useEffect(() => {\n    const wire = wireRef.current;\n    if (!wire) return;\n    const observer = new ResizeObserver(() => {\n      const next = { width: Math.max(1, Math.round(wire.offsetWidth)), height: Math.max(1, Math.round(wire.offsetHeight)) };\n      if (next.width === sizeRef.current.width && next.height === sizeRef.current.height) return;\n      sizeRef.current = next;\n      setSize(next);\n    });\n    observer.observe(wire);\n    return () => observer.disconnect();\n  }, []);\n\n  useEffect(() => {\n    // Reduced motion: the still frame written in the markup below, no movement\n    if (reducedMotion) return;\n    const wire = wireRef.current;\n    const offsets = [downRef.current, upRef.current, rightRef.current, leftRef.current];\n    if (!wire || offsets.some((offset) => !offset)) return;\n    const [down, up, right, left] = offsets as SVGFEOffsetElement[];\n\n    // progress 0 → 1 is one lap. The pairs always cover the whole area between them.\n    function place(progress: number) {\n      const areaHeight = sizeRef.current.height + PAD * 2;\n      const areaWidth = sizeRef.current.width + PAD * 2;\n      down!.setAttribute(\"dy\", String(areaHeight * (1 - progress)));\n      up!.setAttribute(\"dy\", String(-areaHeight * progress));\n      right!.setAttribute(\"dx\", String(areaWidth * (1 - progress)));\n      left!.setAttribute(\"dx\", String(-areaWidth * progress));\n    }\n\n    let frame = 0;\n    let visible = true;\n    let progress = 0;\n    let last = performance.now();\n\n    function tick(now: number) {\n      frame = requestAnimationFrame(tick);\n      // A long gap (a background tab) shouldn't make the noise leap\n      const seconds = Math.min((now - last) / 1000, 0.1);\n      last = now;\n      if (!visible) return;\n      progress = (progress + (seconds * speedRef.current) / LAP) % 1;\n      place(progress);\n    }\n\n    // Nothing to move while the card is off screen (each frame re-renders the noise, which costs)\n    const visibility = new IntersectionObserver(([entry]) => {\n      visible = entry?.isIntersecting ?? true;\n    });\n    visibility.observe(wire);\n    frame = requestAnimationFrame(tick);\n\n    return () => {\n      cancelAnimationFrame(frame);\n      visibility.disconnect();\n    };\n  }, [reducedMotion]);\n\n  // The glow numbers are the original's at glow 0.6, scaled up or down from there\n  const strength = glow / 0.6;\n  const radiusPx = `${radius}px`;\n  const ring: CSSProperties = { borderRadius: radiusPx, borderWidth: thickness, borderStyle: \"solid\" };\n  const sheen = \"linear-gradient(-30deg, white, transparent 30%, transparent 70%, white)\";\n  const areaHeight = size.height + PAD * 2;\n  const areaWidth = size.width + PAD * 2;\n  // Where the noise starts (the loop moves it from here). With reduced motion it stays at this still frame.\n  const start = reducedMotion ? STILL : 0;\n\n  return (\n    <div\n      className={`group/electric relative ${className}`}\n      style={\n        {\n          \"--electric\": color,\n          // Lighter and softer versions of the colour, worked out by the browser so any colour works\n          \"--electric-light\": \"oklch(from var(--electric) calc(l + 0.06) calc(c * 0.75) h)\",\n          \"--electric-muted\": \"oklch(from var(--electric) l c h / 0.45)\",\n          \"--electric-soft\": \"oklch(from var(--electric) l c h / 0.55)\",\n          \"--electric-tint\": \"oklch(from var(--electric) 0.3 calc(c / 2) h / 0.4)\",\n          padding: thickness,\n          borderRadius: radiusPx,\n          color: \"oklch(0.985 0 0)\",\n        } as CSSProperties\n      }\n    >\n      <svg aria-hidden=\"true\" focusable=\"false\" width=\"0\" height=\"0\" className=\"pointer-events-none absolute\">\n        <defs>\n          <filter id={filterId} filterUnits=\"userSpaceOnUse\" x={-PAD} y={-PAD} width={areaWidth} height={areaHeight} colorInterpolationFilters=\"linearRGB\">\n            {/* Two noise pictures, each used by a pair of offsets: one pair slides down, one sideways.\n                stitchTiles makes each picture wrap around, so the seam where a pair meets doesn't show.\n                Six octaves: the original's ten only add detail finer than a pixel, at a real cost per frame. */}\n            <feTurbulence type=\"turbulence\" baseFrequency=\"0.02\" numOctaves={6} seed={1} stitchTiles=\"stitch\" result=\"noiseA\" />\n            <feTurbulence type=\"turbulence\" baseFrequency=\"0.02\" numOctaves={6} seed={2} stitchTiles=\"stitch\" result=\"noiseB\" />\n            <feOffset ref={downRef} in=\"noiseA\" dx=\"0\" dy={areaHeight * (1 - start)} result=\"down\" />\n            <feOffset ref={upRef} in=\"noiseA\" dx=\"0\" dy={-areaHeight * start} result=\"up\" />\n            <feOffset ref={rightRef} in=\"noiseB\" dx={areaWidth * (1 - start)} dy=\"0\" result=\"right\" />\n            <feOffset ref={leftRef} in=\"noiseB\" dx={-areaWidth * start} dy=\"0\" result=\"left\" />\n            <feComposite in=\"down\" in2=\"up\" result=\"vertical\" />\n            <feComposite in=\"right\" in2=\"left\" result=\"horizontal\" />\n            <feBlend in=\"vertical\" in2=\"horizontal\" mode=\"color-dodge\" result=\"noise\" />\n            {/* chaos 0.5 is the original's scale of 50 */}\n            <feDisplacementMap in=\"SourceGraphic\" in2=\"noise\" scale={chaos * 100} />\n          </filter>\n        </defs>\n      </svg>\n\n      {/* The wide coloured aura behind the card */}\n      <div\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0\"\n        style={{\n          borderRadius: radiusPx,\n          filter: \"blur(32px)\",\n          transform: \"scale(1.1)\",\n          opacity: Math.min(0.3 * strength, 0.6),\n          background: \"linear-gradient(-30deg, var(--electric-light), transparent, var(--electric))\",\n        }}\n      />\n\n      {/* The card itself: a faint diagonal tint of the colour over a dark surface */}\n      <div\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0\"\n        style={{\n          borderRadius: radiusPx,\n          background: `linear-gradient(-30deg, var(--electric-tint), transparent, var(--electric-tint)), ${surface ?? \"oklch(from var(--electric) 0.17 0.02 h)\"}`,\n        }}\n      />\n\n      {/* The edge: a faint steady line with the crackling wire inside it, then two glowing copies */}\n      <div aria-hidden=\"true\" className=\"pointer-events-none absolute\" style={{ inset: thickness }}>\n        <div className=\"absolute inset-0\" style={{ ...ring, borderColor: \"var(--electric-muted)\" }}>\n          <div ref={wireRef} className=\"relative h-full w-full overflow-hidden\" style={{ ...ring, borderColor: \"var(--electric)\", filter: `url(#${filterId})` }}>\n            {/* The charge: a wash of colour that fills the card on hover or keyboard focus, torn by the same noise.\n                (Only :focus-visible, so a tapped button doesn't keep it lit after a second tap.) */}\n            <div\n              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\"}`}\n              style={{ background: \"linear-gradient(to bottom, var(--electric), transparent)\" }}\n            />\n          </div>\n        </div>\n        <div className=\"absolute inset-0\" style={{ ...ring, borderColor: \"var(--electric-soft)\", filter: \"blur(1px)\", opacity: Math.min(strength, 1) }} />\n        <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) }} />\n      </div>\n\n      {/* A white sheen on two corners, blended into what's under it (twice, the second one softer) */}\n      {[1, 0.5].map((amount) => (\n        <div\n          key={amount}\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0\"\n          style={{ borderRadius: radiusPx, mixBlendMode: \"overlay\", transform: \"scale(1.1)\", filter: \"blur(16px)\", background: sheen, opacity: Math.min(amount * strength, 1) }}\n        />\n      ))}\n\n      <div className=\"relative h-full w-full\">{children}</div>\n    </div>\n  );\n}\n"}],"categories":["cards","svg","filter","card","glow","hover","border"],"docs":"import { ElectricBorder } from \"@/components/dimaac/electric-border\". Give its parent a size: it fills it. Docs, playground and props: https://ui.dimaac.com/components/electric-border","meta":{"tier":"free","preview":"https://ui.dimaac.com/components/electric-border","credits":[]}}