Skip to content
FLAVIO COPES
flaviocopes.com

Self-drawing SVG connection lines

By

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

~~~

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

I used this on StackPlan 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>:

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.

<line
  x1="80" y1="110" x2="200" y2="40"
  style="--edge-len:134;stroke-dasharray:134;stroke-dashoffset:134"
  class="edge-draw-line"
/>
.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:

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:

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:

CSS helps too:

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

Tagged: CSS · All topics
~~~

Related posts about css: