Three dark dashboard cards, each with a beam of light running around its border. The beam is a bright head with a tail that fades behind it and a soft glow underneath, and it keeps the same speed along the long and short sides. The large card shows a release rolling out across regions; two small cards beside it show latency and error budget with beams in other colours. On phones the cards stack.
import { useEffect, useRef } from "react";
const regions = [
{ name: "Frankfurt", state: "Live", pct: 100 },
{ name: "Virginia", state: "Live", pct: 100 },
{ name: "Singapore", state: "Rolling out", pct: 68 },
{ name: "Sydney", state: "Queued", pct: 0 },
];
const spark = [18, 22, 17, 26, 21, 15, 19, 12, 16, 10, 13, 8];
/** Comet tail: [length as % of the border, opacity, stroke width]. All share one head. */
const TAIL: [number, number, number][] = [
[30, 0.06, 1.5],
[22, 0.12, 1.5],
[15, 0.22, 1.5],
[9, 0.4, 1.5],
[5, 0.7, 1.5],
[2.2, 1, 2],
];
/**
* A beam of light that runs around the border of its parent.
* The parent needs `relative` and the same corner radius as `radius`.
*/
function BorderBeam({ color, head = "#ffffff", duration = 6, delay = 0, radius = 24 }: { color: string; head?: string; duration?: number; delay?: number; radius?: number }) {
const run = (len: number) => ({
strokeDasharray: `${len} ${100 - len}`,
strokeDashoffset: `${len}px`,
["--card-with-travelling-border-beam-end" as string]: `${len - 100}px`,
animationDuration: `${duration}s`,
animationDelay: `${delay}s`,
});
return (
<span aria-hidden="true" className="pointer-events-none absolute inset-0">
{/* Glow: the same head, wide and blurred. */}
<svg className="absolute inset-0 h-full w-full overflow-visible blur-md" fill="none">
<rect className="card-with-travelling-border-beam-run" width="100%" height="100%" rx={radius} pathLength={100} stroke={color} strokeWidth={7} strokeLinecap="round" strokeOpacity={0.85} style={run(9)} />
</svg>
<svg className="absolute inset-0 h-full w-full overflow-visible" fill="none">
{TAIL.map(([len, opacity, width], i) => (
<rect
key={len}
className="card-with-travelling-border-beam-run"
width="100%"
height="100%"
rx={radius}
pathLength={100}
stroke={i === TAIL.length - 1 ? head : color}
strokeOpacity={opacity}
strokeWidth={width}
strokeLinecap="round"
style={run(len)}
/>
))}
</svg>
</span>
);
}
export default function CardWithTravellingBorderBeam() {
const rootRef = useRef<HTMLDivElement>(null);
// Pause every beam while the section is off screen.
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const io = new IntersectionObserver(([entry]) => {
root.dataset.paused = entry.isIntersecting ? "false" : "true";
});
io.observe(root);
return () => io.disconnect();
}, []);
return (
<section className="relative overflow-hidden bg-slate-950 px-5 py-16 text-slate-100 sm:px-6 sm:py-20">
<style>{`
@keyframes card-with-travelling-border-beam-run {
to { stroke-dashoffset: var(--card-with-travelling-border-beam-end); }
}
@keyframes card-with-travelling-border-beam-rise {
from { opacity: 0; transform: translateY(24px) scale(0.98); }
to { opacity: 1; transform: none; }
}
@keyframes card-with-travelling-border-beam-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.35; transform: scale(2.4); }
}
.card-with-travelling-border-beam-run {
animation: card-with-travelling-border-beam-run 6s linear infinite;
}
.card-with-travelling-border-beam-rise {
animation: card-with-travelling-border-beam-rise 0.8s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.card-with-travelling-border-beam-pulse {
animation: card-with-travelling-border-beam-pulse 1.8s ease-in-out infinite;
}
[data-paused="true"] .card-with-travelling-border-beam-run,
[data-paused="true"] .card-with-travelling-border-beam-pulse {
animation-play-state: paused;
}
@media (prefers-reduced-motion: reduce) {
.card-with-travelling-border-beam-run,
.card-with-travelling-border-beam-rise,
.card-with-travelling-border-beam-pulse { animation: none; }
}
`}</style>
<span aria-hidden="true" className="pointer-events-none absolute left-1/2 top-0 h-[420px] w-[900px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-sky-500/20 blur-3xl" />
<div ref={rootRef} className="relative mx-auto max-w-5xl">
<div className="mx-auto max-w-2xl text-center">
<p className="text-sm font-semibold uppercase tracking-widest text-sky-300">Atlas Release Control</p>
<h2 className="mt-4 text-3xl font-semibold tracking-tight text-white sm:text-5xl">Watch the rollout, not the dashboard.</h2>
<p className="mt-4 text-base text-slate-400">One card tells you where a release is, how it is behaving and whether to stop it.</p>
</div>
<div className="mt-12 grid gap-4 lg:grid-cols-3">
<article className="card-with-travelling-border-beam-rise relative rounded-3xl border border-slate-800 bg-slate-900/70 p-6 sm:p-8 lg:col-span-2" style={{ animationDelay: "0.05s" }}>
<BorderBeam color="#38bdf8" duration={7} />
<div className="relative">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="inline-flex items-center gap-2.5 rounded-full bg-sky-400/10 px-3 py-1 text-xs font-semibold text-sky-300">
<span className="relative flex h-2 w-2">
<span className="card-with-travelling-border-beam-pulse absolute inset-0 rounded-full bg-sky-400" />
<span className="relative h-2 w-2 rounded-full bg-sky-300" />
</span>
Rolling out
</p>
<p className="font-mono text-xs text-slate-400">build 7f3c9a1, started 14:02</p>
</div>
<h3 className="mt-5 text-2xl font-semibold tracking-tight text-white">Release 4.2 is in three of four regions</h3>
<p className="mt-2 max-w-lg text-sm leading-6 text-slate-400">Traffic moves over ten percent at a time. If error rates climb, the rollout stops itself and sends everyone back to 4.1.</p>
<ul className="mt-7 grid gap-x-8 gap-y-4 sm:grid-cols-2">
{regions.map((r) => (
<li key={r.name}>
<div className="flex items-baseline justify-between text-sm">
<span className="font-medium text-slate-100">{r.name}</span>
<span className={r.pct === 100 ? "text-sky-300" : "text-slate-400"}>{r.pct === 100 || r.pct === 0 ? r.state : `${r.pct}%`}</span>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-slate-800">
<div className={`h-full rounded-full ${r.pct === 100 ? "bg-sky-400" : "bg-gradient-to-r from-sky-500 to-cyan-300"}`} style={{ width: `${r.pct}%` }} />
</div>
</li>
))}
</ul>
<div className="mt-8 flex flex-wrap gap-3">
<button type="button" className="inline-flex h-11 items-center rounded-full bg-white px-5 text-sm font-semibold text-slate-950 transition hover:bg-sky-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-900">
Pause rollout
</button>
<button type="button" className="inline-flex h-11 items-center rounded-full border border-slate-700 px-5 text-sm font-semibold text-slate-100 transition hover:border-slate-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300">
View live logs
</button>
</div>
</div>
</article>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-1">
<article className="card-with-travelling-border-beam-rise relative rounded-3xl border border-slate-800 bg-slate-900/70 p-6" style={{ animationDelay: "0.17s" }}>
<BorderBeam color="#2dd4bf" duration={5} delay={-1.6} />
<div className="relative">
<p className="text-sm text-slate-400">p95 latency</p>
<p className="mt-2 flex items-baseline gap-2">
<span className="text-4xl font-semibold tracking-tight text-white">84</span>
<span className="text-sm text-slate-400">ms</span>
<span className="ml-auto rounded-full bg-teal-400/10 px-2 py-0.5 text-xs font-semibold text-teal-300">11 ms faster</span>
</p>
<div className="mt-5 flex h-12 items-end gap-1" aria-hidden="true">
{spark.map((h, i) => (
<span key={i} className={`flex-1 rounded-sm ${i > 8 ? "bg-teal-300" : "bg-slate-700"}`} style={{ height: `${h * 3.6}%` }} />
))}
</div>
</div>
</article>
<article className="card-with-travelling-border-beam-rise relative rounded-3xl border border-slate-800 bg-slate-900/70 p-6" style={{ animationDelay: "0.29s" }}>
<BorderBeam color="#fbbf24" head="#fffbeb" duration={5} delay={-4} />
<div className="relative">
<p className="text-sm text-slate-400">Error budget left</p>
<p className="mt-2 flex items-baseline gap-2">
<span className="text-4xl font-semibold tracking-tight text-white">71</span>
<span className="text-sm text-slate-400">%</span>
<span className="ml-auto rounded-full bg-amber-400/10 px-2 py-0.5 text-xs font-semibold text-amber-300">Watch</span>
</p>
<div className="mt-5 h-2 overflow-hidden rounded-full bg-slate-800">
<div className="h-full w-[71%] rounded-full bg-gradient-to-r from-amber-500 to-amber-300" />
</div>
<p className="mt-3 text-xs text-slate-400">Resets in 9 days. Auto-stop triggers at 40%.</p>
</div>
</article>
</div>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/card-with-travelling-border-beam.json
It is an SVG `rect` the size of the card with the same corner radius. The rect has `pathLength="100"`, so its outline is treated as 100 units long whatever the card size. A `stroke-dasharray` draws one short dash, and animating `stroke-dashoffset` from the start to minus 100 slides that dash once around the outline.
A gradient that spins around the centre sweeps the corners at a different speed from the sides, so on a wide card the light races along the top and crawls round the ends. A dash on the outline moves the same distance every frame, which looks like something actually travelling along the edge.
Six dashes of different lengths are stacked and offset so that they all end at the same point. The longest is faint, the shortest is bright, and where they overlap the brightness adds up toward the head.
Yes. The rect uses `width="100%"` and `height="100%"`, and because the dash is measured in percent of the outline it scales with any size. Only `radius` needs to match your corner class.
It repaints a thin stroke, which is light, and an observer pauses every beam while the section is off screen. It needs no JavaScript per frame and no animation library.
Related: Spotlight Cards with Pointer Light, Stat Tiles with Sparkline Trends, Bento Grid with Six Feature Tiles.
More animated cards or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.