Self-drawing SVG connection lines
By Flavio Copes
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:
- A draw line — solid accent color, runs the draw-in animation once
- 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:
- 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:
@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.
Related posts about css: