# Animated Beam (Moonarc)

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.

- Import: `import AnimatedBeam from '@moonarc/core/AnimatedBeam'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/animated-beam.json`
- Tier A · category background · trigger always
- Readout: `<AnimatedBeam layout="hub" nodes={5}>`
- Browser support: widely (Chrome 120 · Firefox 118 · Safari 16); elsewhere: without container units or hypot() the lines draw solid in the packet colour and nothing travels
- Measured cost: 0 B JS (CSS 5.8 kB raw)
- Page: https://moonarc.dev/components/animated-beam/

## Props

| 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. |

## Usage

```astro
<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>
```

## Reduced motion

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

## With ClientRouter

CSS-only; nothing to rebind.

## Craft

- Positions and paths are computed once on the server in percent of the box; nodes are absolutely positioned with the same numbers, so the component is responsive with no ResizeObserver and no getBoundingClientRect. The Magic UI original measures on every resize.
- Every dash is a fraction of its own path, so a short path and a long one carry packets of the same relative length. The length is CSS: one user unit is 1cqw across and 1cqh down, and eight chords of each curve, computed on the server, sum as hypot() terms to within half a percent of the arc at any box size. pathLength="1" cannot do it: under non-scaling-stroke all three engines lay its units along the screen-space path, and a stretched box drew a row of short dashes. The size container is a box around the drawing alone, so a slotted node keeps its own container queries and cq units.
- The gap in the dash pattern is twice the path's length, so exactly one packet is on a path at a time; the tail is a longer dash a quarter as bright with its end pinned to the packet's.
- Each path's delay is its index over the count of the period, which spreads the packets evenly instead of using a fixed stagger.
- preserveAspectRatio="none" stretches the drawing to the box; vector-effect: non-scaling-stroke keeps the line width constant, and puts the dashes in screen px with it.

## Replaces

- AnimatedBeam (Magic UI)
- Connection beams (Aceternity)

## Source

```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>

```
