Skip to content

Components / Backgrounds

Animated Beam

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 demo

ma

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

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

Baseline · widely available

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.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
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>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/animated-beam.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.

AnimatedBeam.astro
---
/**
 * 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>

Props

PropTypeDefaultDescription
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).
nodesnumber5Satellites or steps, 2–8. Slots node-1 … node-8; an empty slot draws a dot.
durationnumber3Seconds a packet takes to travel one path.
colorstring'currentColor'Packet colour.
trackstringvar(--ma-edge)The line behind the packets.
curvenumber0.35Bend of the curves, 0 straight to 1 deeply curved; sides alternate so a hub reads as a flower.
lengthnumber0.18Packet length as a fraction of its path.
reversebooleanfalseFlow away from the hub (row: right to left).
widthnumber1.5Line width in px, kept in px by vector-effect while the viewBox stretches.

Reduced motion

The lines stay, drawn solid at 60 % in the packet colour; nothing travels.

With ClientRouter

CSS-only; nothing to rebind.

Why it is built this way

Replaces: AnimatedBeam (Magic UI) · Connection beams (Aceternity). See the migration table.

Border Beam

Backgrounds

A beam of light travels around the edge of a card.

newly · Chrome 120 · Firefox 128 · Safari 16.4always

Orbiting Circles

Backgrounds

The children orbit the centre of their parent and stay upright.

every browseralways

A line beside an article fills as it is read, with a dot at its head.

Chrome 115 · not Firefox · Safari 26scroll

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown