# Self-drawing SVG connection lines

> Animate SVG connection lines that draw themselves on scroll, with staggered edges, IntersectionObserver, and prefers-reduced-motion fallbacks.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-07-22 | Topics: [CSS](https://flaviocopes.com/tags/css/) | Canonical: https://flaviocopes.com/animated-svg-connection-lines/

Static lines between boxes look fine. Lines that **draw themselves** when you scroll into view feel deliberate.

I used this on [StackPlan](https://stackplan.dev) for mini stack diagrams on comparison pages. Straight hub-and-spoke lines in the markup. CSS and a tiny script handle the reveal.

## Paths between two boxes

For a simple layout, an SVG `<line>` from center to center works. When you want a softer curve — boxes at different heights — use a cubic bezier `<path>`:

```js
function edgePath(x1, y1, x2, y2) {
  const cx = (x1 + x2) / 2
  return `M ${x1} ${y1} C ${cx} ${y1}, ${cx} ${y2}, ${x2} ${y2}`
}
```

Compute `x1,y1` and `x2,y2` from each node's position plus half its width and height. Store the path string server-side or in a layout helper so Astro can render it once.

## The stroke-dashoffset trick

You can't animate "draw this line" with a single CSS property. You fake it.

Set `stroke-dasharray` to the path length. Set `stroke-dashoffset` to the same length. Animate offset down to zero. The visible stroke shrinks from "all hidden" to "full line".

For a `<line>`, the length is `Math.hypot(x2 - x1, y2 - y1)`. For a `<path>`, call `path.getTotalLength()` in the browser or precompute it at build time.

```html
<line
  x1="80" y1="110" x2="200" y2="40"
  style="--edge-len:134;stroke-dasharray:134;stroke-dashoffset:134"
  class="edge-draw-line"
/>
```

```css
.edge-draw-run {
  animation: edge-draw-in 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
}

@keyframes edge-draw-in {
  from { stroke-dashoffset: var(--edge-len) }
  to { stroke-dashoffset: 0 }
}
```

The easing curve is a cubic-bezier in the **animation**, not the path geometry. Same name, different layer.

## Two lines per edge

StackPlan renders each connection twice:

1. A **draw** line — solid accent color, runs the draw-in animation once
2. A **base** line — dashed gray, stays visible after the draw finishes

When `animationend` fires on the draw line, hide it and turn the dashed line on. You get a quick "wire being laid" moment, then a calm marching dash forever.

## Stagger per edge

Nodes pop in with `transition-delay: i * 70ms`. Edges should wait until their spoke node appears:

```js
edge.delayMs = nodeDelay + 200 + edgeIndex * 20
```

Set `animation-delay` inline when you add the `edge-draw-run` class. The hub draws first. Satellites follow in sequence.

## Trigger on scroll

Don't run the animation on page load if the diagram is below the fold. Use **IntersectionObserver**:

```js
const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    if (entry.isIntersecting) {
      reveal(entry.target)
      observer.unobserve(entry.target)
    }
  }
}, { threshold: 0.25 })

observer.observe(diagramRoot)
```

`reveal()` adds a class to the root, activates nodes, and starts edge draw-ins. One shot per diagram.

## prefers-reduced-motion

Some users disable motion in the OS. Respect that.

When `matchMedia('(prefers-reduced-motion: reduce)')` matches:

- Skip the draw overlay entirely
- Show nodes and dashed edges in their final state immediately
- Don't attach the observer delay — reveal on bind

CSS helps too:

```css
@media (prefers-reduced-motion: reduce) {
  .edge-draw-line { display: none }
  .stack-node { transition: none; opacity: 1 }
}
```

The diagram still communicates structure. It just doesn't perform.

That's the whole trick: geometry in the layout helper, dash lengths inline, staggered CSS animation, scroll-triggered reveal, and a reduced-motion escape hatch.
