Components Motion Primitives AnimatedBeam

AnimatedBeam

SVG beams for connected-flow diagrams.

Live demo

01

Uni-directional

SourceTarget

Bi-directional

AB

Multiple inputs β†’ one output

Input 1Input 2Input 3Output

Customisation grid

beamSpeed=1 (fast)
SourceTarget
beamSpeed=4 (slow)
SourceTarget
green theme
SourceTarget
gradient
SourceTarget

Implementation

02
AnimatedBeam.svelte
<script>
  import AnimatedBeam from '$lib/components/AnimatedBeam.svelte';
</script>
​
<AnimatedBeam
  nodes={[
    { id: 'source', x: 100, y: 200, label: 'Source' },
    { id: 'target', x: 500, y: 200, label: 'Target' }
  ]}
  connections={[{ from: 'source', to: 'target' }]}
/>

AnimatedBeam draws SVG paths between user-positioned nodes and animates a dashed stroke along each path. Bidirectional connections receive a counter-rotating second beam, and gradient mode swaps the dashes for a soft moving fade β€” useful for ambient flow diagrams. The component is pure SVG + CSS, so the runtime cost stays close to a static diagram.

Logic explainer

03

What Does It Do? (Plain English)

AnimatedBeam draws a small network diagram β€” a handful of round nodes connected by lines β€” and animates each line so the dashes flow along it like packets of data travelling from one node to another. The whole thing is a single SVG with a fixed viewBox, so it scales crisply on any display, and every line of motion comes from one CSS keyframe animating the dash offset. There is no JavaScript animation loop, no canvas, no rAF.

It is the right component for system architecture diagrams, data pipelines, AI model fan-in/out illustrations, or anywhere a static network diagram could use a hint of life. Bidirectional connections animate in reverse so a return flow is visible alongside its outbound counterpart, and an optional gradient mode swaps the dashed line for a soft moving fade β€” the same trick, less marching-ants, more lava-lamp.

How It Works (Pseudo-Code)

on render:
  read props: width, height, nodes[], connections[], beamColor, beamWidth,
              beamSpeed, bidirectional, gradient, nodeSize, nodeColor

  beamPaths = derived: for each connection in connections:
    sourceNode = nodes.find(n.id === conn.from)
    targetNode = nodes.find(n.id === conn.to)
    if either missing: warn and skip
    return { x1, y1, x2, y2, bidirectional }

  emit <svg viewBox="0 0 width height">
    if gradient: emit <linearGradient> with transparent β†’ color β†’ transparent stops
    <g class="beams">
      for each path in beamPaths (keyed by endpoints):
        <line x1 y1 x2 y2
              stroke={gradient ? url(#beam-gradient) : beamColor}
              stroke-width
              class="beam {bidirectional?}"
              style="--beam-duration: beamSpeed s" />
    <g class="nodes">
      for each node:
        <circle cx cy r={nodeSize} fill={nodeColor} />
        if node.label: <text> at (x, y - nodeSize - 8)

CSS:
  .beam {
    stroke-dasharray: 8 8;
    animation: beam-flow var(--beam-duration) linear infinite;
  }
  .beam.bidirectional {
    animation: beam-flow-reverse ...;
  }
  @keyframes beam-flow         { from {stroke-dashoffset: 100;} to {stroke-dashoffset: 0;}   }
  @keyframes beam-flow-reverse { from {stroke-dashoffset: 0;}   to {stroke-dashoffset: 100;} }

The component is almost entirely declarative: SVG geometry from props, dash animation from CSS. The only JavaScript work is the $derived that translates connections[] (id-pair references) into beamPaths[] (concrete coordinates) and warns about dangling connection references during development.

The Core Concept: The Dashoffset Marching-Ants Trick

A dashed SVG line is rendered by alternating "dash" and "gap" segments along the stroke. CSS controls this via stroke-dasharray, which here is 8 8 (8 px dash, 8 px gap, repeating).

stroke-dashoffset shifts where the dash pattern starts. Imagine the pattern as a ruler that runs along the line β€” dashoffset = 0 means the first dash starts at the line's origin; dashoffset = 8 means the pattern is shifted 8 px back, so the first 8 px of the line is gap.

Animating stroke-dashoffset from 100 to 0 is what creates the "ants marching forward" illusion:

dashoffset = 100   β†’  ── ── ── ── ── ── ──
dashoffset = 92    β†’   ── ── ── ── ── ── ─
dashoffset = 84    β†’    ── ── ── ── ── ──
…
dashoffset = 0     β†’  ── ── ── ── ── ── ──

To the eye, individual dashes appear to slide along the line β€” but every dash is the same dash; only the start offset is changing. The pattern is rendered fresh by the GPU each frame off a single floating-point variable.

For bidirectional flows, the second beam runs the reverse keyframes (0 β†’ 100), so two superimposed lines appear to flow in opposite directions. In gradient mode, the dashed pattern is swapped for a <linearGradient> whose stops are transparent β†’ colour β†’ transparent, and the same dashoffset animation now slides the gradient β€” producing a soft moving glow rather than discrete dashes.

   single connection                bidirectional
   ●────→────●                      ●←────●────→●

   gradient mode (single connection)
   ●  Β· Β· Β· ·●·  Β· Β· Β· ●            (bright spot travels along the line)

CSS Animation Strategy

Two keyframes do all the visible work:

.beam {
  stroke-dasharray: 8 8;
  stroke-linecap: round;        /* prettier dash ends */
  animation: beam-flow var(--beam-duration, 2s) linear infinite;
}

.beam.bidirectional {
  animation: beam-flow-reverse var(--beam-duration, 2s) linear infinite;
}

@keyframes beam-flow         { from { stroke-dashoffset: 100; } to { stroke-dashoffset: 0;   } }
@keyframes beam-flow-reverse { from { stroke-dashoffset: 0;   } to { stroke-dashoffset: 100; } }

Per-beam timing is set via the inline --beam-duration custom property, so the same CSS file serves any speed without a fresh keyframe per beam.

prefers-reduced-motion: reduce swaps the beam to a solid line:

@media (prefers-reduced-motion: reduce) {
  .beam {
    animation: none;
    stroke-dasharray: none;     /* solid stroke */
  }
}

This is the right call for an architecture diagram β€” the connection still reads, just without the implied flow. Dark mode flips text fill on .node-label for legibility. Mobile (max-width: 640px) reduces the label font size from 14 px to 10 px so labels don't cover small node circles.

State Flow Diagram

            β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
            β”‚  props: nodes, connections β”‚
            β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                           β”‚ Svelte $derived recomputes
                           β–Ό
            β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
            β”‚  beamPaths[]               β”‚
            β”‚  validated coordinate pairsβ”‚
            β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                           β”‚ render
                           β–Ό
            β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
            β”‚  static SVG + CSS keyframesβ”‚
            β”‚  GPU animates dashes       β”‚
            β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                           β”‚
       props change?       β”‚       prefers-reduced-motion: reduce
                           β”‚
       β–Ό                                  β–Ό
       beamPaths re-derive        @media swap β†’ solid lines, no animation
       SVG re-renders
       (animation continues from current dashoffset for stable beams)

There is no runtime state. The component is a pure projection of props through one derived array onto SVG.

Props Reference

Prop Type Default Description
width number 600 SVG viewBox width. The component scales to its container; this sets the coordinate system.
height number 400 SVG viewBox height.
nodes BeamNode[] DEFAULT_BEAM_NODES_UNI Each node has id, x, y, optional label. Coordinates are in viewBox space.
beamColor string '#3b82f6' Stroke colour for non-gradient beams. Also used as the gradient mid-stop.
beamWidth number 2 Stroke width in viewBox pixels.
beamSpeed number 2 Animation duration in seconds for one full dashoffset cycle. Lower = faster.
bidirectional boolean false When true, all connections animate the reverse keyframes. Per-connection override is supported via conn.bidirectional.
gradient boolean false Swaps the dashed stroke for a gradient that slides along the line.
nodeSize number 12 Circle radius in viewBox pixels.
nodeColor string '#3b82f6' Fill colour for node circles.
connections BeamConnection[] DEFAULT_BEAM_CONNECTIONS_UNI Each connection has from (node id), to (node id), optional bidirectional.

Edge Cases

Situation Behaviour
Connection references missing node id console.warn is emitted in development; the connection is silently dropped from beamPaths. The diagram still renders.
Two connections with identical endpoints The keying ${x1}-${y1}-${x2}-${y2} deduplicates them in the keyed {#each}. Pass distinct endpoints if you really want overlapping beams.
nodes = [] SVG renders empty. No errors.
Coordinates outside viewBox Beams are clipped at the viewBox edge by preserveAspectRatio="xMidYMid meet". Position your nodes inside the viewBox.
beamSpeed = 0 Browsers treat animation-duration: 0s as no animation; the dash pattern freezes at offset 100. The line still draws (statically dashed).
Very large beamSpeed (e.g. 60) Beams crawl. Useful for ambient backdrops; verify the dashes don't read as broken lines at very slow speeds.
prefers-reduced-motion: reduce Beams render as solid (un-dashed) lines. The diagram still communicates the connections, but the flow hint is removed.
Mobile viewport <640 px Labels shrink to 10 px font. If labels still overlap their circles for short labels at small sizes, increase nodeSize or position labels manually using a separate <text>.
Dark mode Labels flip to light text via prefers-color-scheme: dark. Beam and node colours remain β€” choose colours that work on both backgrounds, or pass theme-aware values.

Dependencies

  • Svelte 5 β€” $props, $derived. The derived array is the only runtime work the component does.
  • $lib/types β€” AnimatedBeamProps, BeamNode, BeamConnection keep prop and data shapes consistent across the codebase.
  • $lib/constants β€” DEFAULT_BEAM_NODES_UNI / DEFAULT_BEAM_CONNECTIONS_UNI give the component sensible default scenery.
  • Zero external libraries β€” no charting library, no icon library. Pure SVG + CSS keyframes.

File Structure

src/lib/components/AnimatedBeam.svelte        # implementation
src/lib/components/AnimatedBeam.md            # this explainer
src/lib/components/AnimatedBeam.test.ts       # unit tests
src/routes/animatedbeam/+page.svelte          # demo page
src/lib/types.ts                              # AnimatedBeamProps, BeamNode, BeamConnection
src/lib/constants.ts                          # DEFAULT_BEAM_* sample data

API

04
PropTypeDefaultDescription
widthnumber600SVG viewBox width in pixels.
heightnumber400SVG viewBox height in pixels.
nodesBeamNode[][]Array of { id, x, y, label } nodes.
connectionsBeamConnection[][]Array of { from, to, bidirectional? } edges.
beamColorstring'#3b82f6'Hex colour of the animated beam stroke.
beamWidthnumber2Beam stroke width in pixels.
beamSpeednumber2Animation duration in seconds. Lower = faster.
bidirectionalbooleanfalseToggle a second counter-flowing beam on every connection.
gradientbooleanfalseUse a soft gradient sweep instead of dashes.
nodeSizenumber12Node radius in pixels.
nodeColorstring'#3b82f6'Node fill colour.