Border Beam
BackgroundsA beam of light travels around the edge of a card.
Sends packets of light along curves between nodes. The layout is a preset (satellites around a hub, a row, or a fan into one), computed on the server in percent, so the SVG paths and the slotted nodes share coordinates and nothing is measured. Each beam is one dash, sized as a fraction of its path's on-screen length (computed in CSS from the box's container units), whose offset runs from start to past the end, with a fainter tail behind it. Zero JS.
Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
JavaScript of its own
0 B
CSS 5.8 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.
Browser support
Chrome 120 · Firefox 118 · Safari 16; needs container-units, css-exp. Elsewhere: without container units or hypot() the lines draw solid in the packet colour and nothing travels.
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
import AnimatedBeam from '@moonarc/core/AnimatedBeam';
---
<div class="relative h-80">
<AnimatedBeam layout="hub" nodes={5} color="var(--action)">
<img slot="hub" src="/logo.svg" alt="" />
<Icon slot="node-1" name="slack" />
<Icon slot="node-2" name="notion" />
<Icon slot="node-3" name="github" />
<Icon slot="node-4" name="figma" />
<Icon slot="node-5" name="linear" />
</AnimatedBeam>
</div>Owns the file, no dependency. The registry item also installs the base tokens.
npx shadcn@latest add https://moonarc.dev/r/animated-beam.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.
---
/**
* AnimatedBeam — packets of light travel along curves between nodes. The
* layout is a preset (hub: satellites around a centre · row: a chain ·
* fan: many into one); the server computes every node position and every
* path in percent of the box, so the SVG and the slotted nodes share the
* same coordinates and nothing is measured in the DOM. Each beam is one
* dash whose offset runs from before the start to past the end (a comet
* tail is a longer, fainter dash behind it). The stroke keeps its width in
* px while the drawing stretches, which puts dashes in screen px too, so a
* path's on-screen length is CSS: eight chords from the server, each a
* hypot() of cqw and cqh. The delay per path is its index over the count
* of the period, so the packets are spread evenly. Zero script; responsive
* because everything is a percentage.
*/
import type { HTMLAttributes } from 'astro/types';
interface Props extends HTMLAttributes<'div'> {
/** hub: one centre (slot `hub`) with `nodes` satellites · row: `nodes` nodes in a line, left to right · fan: `nodes` on the left flowing into one on the right (slot `hub`). */
layout?: 'hub' | 'row' | 'fan';
/** Satellite / step count, 2–8. */
nodes?: number;
/** Seconds a packet takes to travel one path. */
duration?: number;
/** Packet colour. */
color?: string;
/** Line colour behind the packets. */
track?: string;
/** Bend of the curves, 0 straight … 1 deeply curved. */
curve?: number;
/** Packet length as a fraction of the path. */
length?: number;
/** Packets flow away from the hub instead of toward it (row: right to left). */
reverse?: boolean;
/** Line width in px. */
width?: number;
}
const { layout = 'hub', nodes = 5, duration = 3, color = 'currentColor', track = 'var(--ma-edge)', curve = 0.35, length = 0.18, reverse = false, width = 1.5, class: className, style, ...rest } = Astro.props;
const n = Math.max(2, Math.min(8, Math.round(nodes)));
type Pt = [number, number];
const r1 = (v: number) => Math.round(v * 10) / 10;
// node positions in percent; every path runs satellite → hub (or left → right)
let hub: Pt | null = null;
let sats: Pt[] = [];
let links: [Pt, Pt][] = [];
if (layout === 'hub') {
hub = [50, 50];
sats = Array.from({ length: n }, (_, i) => {
const a = -Math.PI / 2 + (i * 2 * Math.PI) / n;
return [r1(50 + Math.cos(a) * 38), r1(50 + Math.sin(a) * 36)];
});
links = sats.map((s) => [s, hub!]);
} else if (layout === 'fan') {
hub = [82, 50];
sats = Array.from({ length: n }, (_, i) => [18, r1(n === 1 ? 50 : 12 + (i * 76) / (n - 1))]);
links = sats.map((s) => [s, hub!]);
} else {
sats = Array.from({ length: n }, (_, i) => [r1(10 + (i * 80) / (n - 1)), 50]);
links = sats.slice(1).map((s, i) => [sats[i]!, s]);
}
// quadratic curve with the control point pushed off the chord, alternating sides so a hub reads as a flower, a row as a wave
const r2 = (v: number) => Math.round(v * 100) / 100;
const paths = links.map(([a, b], i) => {
const mx = (a[0] + b[0]) / 2;
const my = (a[1] + b[1]) / 2;
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const side = i % 2 ? -1 : 1;
const cx = r1(mx - dy * curve * side);
const cy = r1(my + dx * curve * side);
// the on-screen length: one user unit is 1cqw across and 1cqh down (the viewBox is stretched to the box), so eight
// chords of the curve summed as hypot() in those units follow the box at any size, within half a percent of the arc
const at = (t: number): Pt => [(1 - t) ** 2 * a[0] + 2 * (1 - t) * t * cx + t * t * b[0], (1 - t) ** 2 * a[1] + 2 * (1 - t) * t * cy + t * t * b[1]];
const chords = Array.from({ length: 8 }, (_, k) => {
const [x0, y0] = at(k / 8);
const [x1, y1] = at((k + 1) / 8);
return `hypot(${r2(x1 - x0)}cqw,${r2(y1 - y0)}cqh)`;
});
return { d: reverse ? `M${b[0]} ${b[1]}Q${cx} ${cy} ${a[0]} ${a[1]}` : `M${a[0]} ${a[1]}Q${cx} ${cy} ${b[0]} ${b[1]}`, len: `calc(${chords.join(' + ')})` };
});
const inline = [`--ma-beams-dur:${duration}s`, `--ma-beams-color:${color}`, `--ma-beams-track:${track}`, `--ma-beams-len:${length}`, `--ma-beams-n:${paths.length}`, `--ma-beams-w:${width}px`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
const slotNames = Array.from({ length: n }, (_, i) => `node-${i + 1}` as const);
---
<div class:list={['ma-beams', className]} data-ma-beams data-layout={layout} style={inline} {...rest}>
<div class="ma-beams__lines">
<svg class="ma-beams__svg" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
{paths.map((p, i) => (
<g style={`--ma-beams-i:${i};--ma-beams-l:${p.len}`}>
<path class="ma-beams__track" d={p.d} />
<path class="ma-beams__tail" d={p.d} />
<path class="ma-beams__packet" d={p.d} />
</g>
))}
</svg>
</div>
{hub && (
<div class="ma-beams__node ma-beams__node--hub" style={`--ma-beams-x:${hub[0]}%;--ma-beams-y:${hub[1]}%`}>
<slot name="hub"><span class="ma-beams__dot"></span></slot>
</div>
)}
{sats.map((p, i) => (
<div class="ma-beams__node" style={`--ma-beams-x:${p[0]}%;--ma-beams-y:${p[1]}%`}>
{i === 0 && <slot name="node-1"><span class="ma-beams__dot"></span></slot>}
{i === 1 && <slot name="node-2"><span class="ma-beams__dot"></span></slot>}
{i === 2 && <slot name="node-3"><span class="ma-beams__dot"></span></slot>}
{i === 3 && <slot name="node-4"><span class="ma-beams__dot"></span></slot>}
{i === 4 && <slot name="node-5"><span class="ma-beams__dot"></span></slot>}
{i === 5 && <slot name="node-6"><span class="ma-beams__dot"></span></slot>}
{i === 6 && <slot name="node-7"><span class="ma-beams__dot"></span></slot>}
{i === 7 && <slot name="node-8"><span class="ma-beams__dot"></span></slot>}
</div>
))}
{slotNames.length === 0 && <slot />}
</div>
<style is:global>
@layer components {
:where(.ma-beams) {
position: relative;
width: 100%;
height: 100%;
}
/* the paths' lengths are in cqw / cqh of the drawing's own box, the component's size with no padding. It holds no
slotted node, so a container query or a cq unit inside a node still resolves against the container it had */
:where(.ma-beams__lines) {
position: absolute;
inset: 0;
container-type: size;
}
:where(.ma-beams__svg) {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
:where(.ma-beams__svg path) {
fill: none;
stroke-width: var(--ma-beams-w, 1.5px);
stroke-linecap: round;
/* the viewBox is stretched to the box; the stroke keeps its width in px, and its dashes are in screen px with it */
vector-effect: non-scaling-stroke;
}
:where(.ma-beams__track) {
stroke: var(--ma-beams-track, var(--ma-edge));
}
/* the packet: a dash `len` of the path's on-screen length --ma-beams-l, a gap of twice that so only one packet is on
the path; the offset runs from `len` × l (the dash still before the start) to −l (past the end). pathLength="1" cannot
do this: under non-scaling-stroke the engines lay its units along the screen-space path, so a stretched box drew a
row of short dashes. */
:where(.ma-beams__packet),
:where(.ma-beams__tail) {
stroke: var(--ma-beams-color, currentColor);
stroke-dasharray: calc(var(--ma-beams-len, 0.18) * var(--ma-beams-l)) calc(var(--ma-beams-l) * 2);
animation: ma-beams var(--ma-beams-dur) linear infinite;
animation-delay: calc(var(--ma-beams-i, 0) * var(--ma-beams-dur) / var(--ma-beams-n, 1) * -1);
}
/* the tail: three times the packet, a quarter as bright, ending where the packet ends */
:where(.ma-beams__tail) {
stroke-dasharray: calc(var(--ma-beams-len, 0.18) * 3 * var(--ma-beams-l)) calc(var(--ma-beams-l) * 2);
opacity: 0.25;
animation-name: ma-beams-tail;
}
:where(.ma-beams__node) {
position: absolute;
left: var(--ma-beams-x, 50%);
top: var(--ma-beams-y, 50%);
translate: -50% -50%;
display: grid;
place-items: center;
}
:where(.ma-beams__dot) {
display: block;
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
border: 1px solid var(--ma-edge);
background: var(--ma-panel);
}
:where(.ma-beams__node--hub .ma-beams__dot) {
width: 1.25rem;
height: 1.25rem;
background: var(--ma-ink);
}
@media (prefers-reduced-motion: reduce) {
:where(.ma-beams__packet),
:where(.ma-beams__tail) {
animation: none;
stroke-dasharray: none;
opacity: 0.6;
}
:where(.ma-beams__tail) {
display: none;
}
}
}
@keyframes ma-beams {
from {
stroke-dashoffset: calc(var(--ma-beams-len, 0.18) * var(--ma-beams-l));
}
to {
stroke-dashoffset: calc(var(--ma-beams-l) * -1);
}
}
@keyframes ma-beams-tail {
from {
stroke-dashoffset: calc(var(--ma-beams-len, 0.18) * 3 * var(--ma-beams-l));
}
to {
stroke-dashoffset: calc(var(--ma-beams-l) * -1 + var(--ma-beams-len, 0.18) * 2 * var(--ma-beams-l));
}
}
</style>| Prop | Type | Default | Description |
|---|---|---|---|
layout | 'hub' | 'row' | 'fan' | 'hub' | hub: one centre (slot hub) with `nodes` satellites on an ellipse · row: `nodes` in a line, left to right · fan: `nodes` on the left into one on the right (slot hub). |
nodes | number | 5 | Satellites or steps, 2–8. Slots node-1 … node-8; an empty slot draws a dot. |
duration | number | 3 | Seconds a packet takes to travel one path. |
color | string | 'currentColor' | Packet colour. |
track | string | var(--ma-edge) | The line behind the packets. |
curve | number | 0.35 | Bend of the curves, 0 straight to 1 deeply curved; sides alternate so a hub reads as a flower. |
length | number | 0.18 | Packet length as a fraction of its path. |
reverse | boolean | false | Flow away from the hub (row: right to left). |
width | number | 1.5 | Line width in px, kept in px by vector-effect while the viewBox stretches. |
The lines stay, drawn solid at 60 % in the packet colour; nothing travels.
ClientRouterCSS-only; nothing to rebind.
Replaces: AnimatedBeam (Magic UI) · Connection beams (Aceternity). See the migration table.
A beam of light travels around the edge of a card.
The children orbit the centre of their parent and stay upright.
A line beside an article fills as it is read, with a dot at its head.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown