Animated Gradient Border Beam
A comet of light that circles a card's border at an even pace: a faded tail behind a white-hot head, glow that bleeds onto the surface and past the edge, an optional second beam the other way, and a pointer light on hover. Wraps any content. No dependencies.
DOM + CSS0 deps1 file · 7 KB TSXreduced-motion aware
Props
"use client";
import { useEffect, useRef } from "react";
import type { CSSProperties, ReactNode } from "react";
/** A comet of light that circles a card's border at an even pace, with its glow
* bleeding onto the surface and just outside the edge. Wraps any children. */
export function BorderBeamCard({
label = "Border beam",
speed = 4,
children,
color = "#ff2d2d",
coreColor = "#ffffff",
size = 22,
borderWidth = 2,
radius = 16,
glow = 1,
dual = false,
reverse = false,
surface = "#0b0c0f",
className = "",
}: {
label?: string;
/** Seconds per lap. */
speed?: number;
children?: ReactNode;
/** Tail colour of the beam. */
color?: string;
/** Hot colour at the head of the beam. */
coreColor?: string;
/** Beam length as a percentage of the perimeter. */
size?: number;
borderWidth?: number;
/** Corner radius in px. */
radius?: number;
/** Glow strength, 0 to 2. */
glow?: number;
/** A second beam travelling the other way. */
dual?: boolean;
reverse?: boolean;
/** Card surface colour. */
surface?: string;
className?: string;
}) {
const rootRef = useRef<HTMLDivElement>(null);
const hasChildren = children !== undefined && children !== null && children !== false;
const safe = (c: string, fallback: string) => (/^[#a-zA-Z0-9(),.%\s/-]{1,48}$/.test(c) ? c : fallback);
const tail = safe(color, "#ff2d2d");
const core = safe(coreColor, "#ffffff");
const bg = safe(surface, "#0b0c0f");
const bw = Math.min(8, Math.max(1, borderWidth));
const r = Math.max(0, radius);
const len = Math.min(60, Math.max(4, size));
const lap = Math.max(0.5, speed);
const g = Math.min(2, Math.max(0, glow));
// Sleep the animation while off screen.
useEffect(() => {
const el = rootRef.current;
if (!el) return;
if (typeof IntersectionObserver === "undefined") {
el.dataset.live = "1";
return;
}
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) el.dataset.live = e.isIntersecting ? "1" : "0";
},
{ rootMargin: "120px" },
);
io.observe(el);
return () => io.disconnect();
}, []);
// Pointer light on the surface, in layout px (previews may be scaled).
const onMove = (e: React.PointerEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const rect = el.getBoundingClientRect();
if (!rect.width) return;
const k = el.offsetWidth / rect.width;
el.style.setProperty("--gbb-x", ((e.clientX - rect.left) * k).toFixed(1) + "px");
el.style.setProperty("--gbb-y", ((e.clientY - rect.top) * k).toFixed(1) + "px");
};
// Tail stacked from shorter and shorter dashes that share one head, so the
// light fades behind the tip. [fraction of length, colour, opacity]
const full: [number, string, number][] = [
...Array.from({ length: 16 }, (_, i): [number, string, number] => [Math.pow((16 - i) / 16, 1.7), tail, 0.075]),
[0.2, core, 0.2],
[0.14, core, 0.26],
[0.09, core, 0.32],
[0.05, core, 0.9],
];
const soft: [number, string, number][] = [
[1, tail, 0.3],
[0.55, tail, 0.45],
[0.2, core, 0.5],
];
const beam = (segs: [number, string, number][], rev: boolean, delay: number, cls: string) =>
segs.map(([f, c, o], i) => {
const l = len * f;
// Every dash of a beam shares one head: the forward head is the dash end, the reverse head its start.
const lead = rev ? 30 : len - l;
const dash = "0 " + lead.toFixed(2) + " " + l.toFixed(2) + " " + (100 - lead - l).toFixed(2);
return (
<rect
key={cls + i}
className={"gbb-seg " + cls + (rev ? " gbb-rev" : " gbb-fwd")}
width="100%"
height="100%"
rx={Math.max(0, r - bw / 2)}
fill="none"
stroke={c}
strokeOpacity={o}
pathLength={100}
strokeDasharray={dash}
style={{ animationDelay: delay + "s" }}
/>
);
});
const ring = (segs: [number, string, number][], cls: string) => (
<>
{beam(segs, reverse, 0, cls)}
{dual ? beam(segs, !reverse, -lap / 2, cls) : null}
</>
);
const svgBase = (extra: CSSProperties): CSSProperties => ({
position: "absolute",
left: bw / 2,
top: bw / 2,
width: "calc(100% - " + bw + "px)",
height: "calc(100% - " + bw + "px)",
overflow: "visible",
pointerEvents: "none",
...extra,
});
return (
<div
ref={rootRef}
data-live="1"
className={"gbb-root relative isolate " + className}
style={
{
borderRadius: r,
"--gbb-lap": lap + "s",
"--gbb-bw": bw + "px",
"--gbb-x": "50%",
"--gbb-y": "50%",
} as CSSProperties
}
>
{/* Light that escapes past the edge, behind the card */}
{g > 0 ? (
<svg aria-hidden className="gbb-out" style={svgBase({ opacity: 0.55 * g, filter: "blur(10px)", zIndex: -1 })} fill="none">
<g strokeWidth={bw * 3}>{ring(soft, "gbb-g")}</g>
</svg>
) : null}
<div
onPointerMove={onMove}
className={"relative overflow-hidden " + (hasChildren ? "" : "flex h-32 w-64 items-center justify-center")}
style={{ borderRadius: r, background: bg, color: "#fff" }}
>
{/* Light that bleeds onto the surface */}
{g > 0 ? (
<svg aria-hidden style={svgBase({ left: bw / 2, top: bw / 2, opacity: 0.4 * g, filter: "blur(14px)" })} fill="none">
<g strokeWidth={bw * 9}>{ring(soft, "gbb-g")}</g>
</svg>
) : null}
{/* Pointer light */}
<div
aria-hidden
className="gbb-spot pointer-events-none absolute inset-0"
style={{ background: "radial-gradient(260px circle at var(--gbb-x) var(--gbb-y), rgba(255,255,255,0.075), transparent 70%)" }}
/>
<div className={"relative z-10 " + (hasChildren ? "" : "text-sm font-semibold tracking-tight")}>{hasChildren ? children : label}</div>
</div>
{/* The beam itself, drawn over the edge */}
<svg aria-hidden style={svgBase({ zIndex: 2 })} fill="none">
<rect
width="100%"
height="100%"
rx={Math.max(0, r - bw / 2)}
stroke="rgba(255,255,255,0.1)"
strokeWidth={bw}
/>
<g>
{ring(full, "gbb-b")}
</g>
</svg>
<style>{`
.gbb-seg { stroke-dashoffset: -8; transition: stroke-width 300ms ease; animation-duration: var(--gbb-lap, 4s); animation-timing-function: linear; animation-iteration-count: infinite; }
.gbb-seg.gbb-fwd { animation-name: gbb-fwd; }
.gbb-seg.gbb-rev { animation-name: gbb-rev; stroke-dashoffset: 8; }
.gbb-root[data-live="0"] .gbb-seg { animation-play-state: paused; }
.gbb-b { stroke-width: var(--gbb-bw); }
.gbb-root:hover .gbb-b, .gbb-root:focus-within .gbb-b { stroke-width: calc(var(--gbb-bw) * 1.6); }
.gbb-out, .gbb-spot { transition: opacity 400ms ease; }
.gbb-spot { opacity: 0; }
.gbb-root:hover .gbb-spot { opacity: 1; }
.gbb-root:hover .gbb-out, .gbb-root:focus-within .gbb-out { opacity: 0.9 !important; }
@keyframes gbb-fwd { from { stroke-dashoffset: 0; } to { stroke-dashoffset: -100; } }
@keyframes gbb-rev { from { stroke-dashoffset: 0; } to { stroke-dashoffset: 100; } }
@media (prefers-reduced-motion: reduce) { .gbb-seg { animation: none !important; } }
`}</style>
</div>
);
}
Free. Pro unlocks everything, from $27/month.
Details
About this background
A card with a comet of light circling its edge. The beam is SVG, not a conic gradient: a rounded rectangle whose length is normalised to 100, drawn as a stack of twenty dashes that share one head and get shorter toward it, so the tail fades behind a white-hot tip. Because it is a dash travelling along the path, the speed is the same on the long sides, the short sides and the corners, however wide the card is. Two blurred copies of the beam give the glow, one clipped inside the card so light bleeds onto the surface, one behind it so a little escapes past the edge. Hovering or focusing inside thickens the beam, lifts the outer glow and switches on a soft light that follows the pointer. Turn on dual for a second beam going the other way, half a lap behind, or reverse for counter-clockwise. The card wraps any children and sizes to them (without children it shows the label in a 16rem by 8rem box). The animation is CSS only, so there is no JavaScript per frame, and it pauses while the card is off screen. Props: label, speed (seconds per lap), color, coreColor, size (percent of the perimeter), borderWidth, radius, glow, dual, reverse, surface, className. React only, no dependencies.
Curator’s note
Rebuilt in October. The first version spun a conic gradient, so the light sped up and stretched at the corners of a wide card; this one runs along the real perimeter at a constant pace.

