The whole effect is four small tricks stacked together: a dashed stroke whose offset never stops sliding, a dot riding the same path, curves fanning out of one shared origin, and a node that breathes. No library, no build step — just SVG and CSS.
CPQ generates Contract + Enterprise Flex Sub
Order Operations sets up the committed amount
↓ this request draws against Lead
Submitted via Account Hub on the partner's behalf
Auto-approval conditions are evaluated
Approver queue, SLA clock starts
Notice clears, drawdown posts
This is the whole trick. Give a path a dashed stroke, then animate
stroke-dashoffset forever. Nothing moves — the gaps do. The one rule that
matters: the offset you animate to must be an exact multiple of the dash period
(dash + gap), or the loop visibly stutters on repeat.
.track {
fill: none;
stroke: #d8dde5;
stroke-width: 2;
stroke-dasharray: 7 7; /* period = 14 */
stroke-linecap: round;
animation: march 900ms linear infinite;
}
@keyframes march {
to { stroke-dashoffset: -28; } /* 2 × 14 — seamless */
}
Those coloured pips travelling down the spine are a plain <circle> with an
<animateMotion> child pointed at the same path via
<mpath>. Reusing the path by id means the dot can never drift
out of sync with the line it's riding — change the curve and the dot follows.
<path id="p1" class="track" d="M20,2 L20,50"/>
<circle r="3.6" fill="#c4174a">
<animateMotion dur="2.2s" repeatCount="indefinite">
<mpath href="#p1" xlink:href="#p1"/>
</animateMotion>
</circle>
href and xlink:href, and declare
xmlns:xlink on the <svg>. Older WebKit only honours the
xlink form, and the failure is silent — the dot just parks at the origin.
Every branch starts at the same point and ends above its target card. Give each a cubic
bezier whose first control point sits straight below the origin — that's what
makes all three leave vertically before peeling apart, instead of splaying out like a
starburst. Stagger the dots with begin so they read as three streams, not one pulse.
<!-- origin (280,6) → targets at x = 122 / 280 / 438 -->
<path id="bL" d="M280,6 C280,46 122,42 122,84" marker-end="url(#mk-blue)"/>
<path id="bM" d="M280,6 L280,84" marker-end="url(#mk-purple)"/>
<path id="bR" d="M280,6 C280,46 438,42 438,84" marker-end="url(#mk-green)"/>
<circle r="3.4" fill="#a78bfa">
<animateMotion dur="2.6s" begin="0.3s" repeatCount="indefinite">
<mpath href="#bM" xlink:href="#bM"/>
</animateMotion>
</circle>
Arrowheads come from one <marker> per colour. fill="context-stroke"
would collapse those into a single marker, but support is patchy enough that a handful of
explicit markers is the safer trade.
The cards are ordinary flex-column HTML — that's why the text wraps and reflows properly.
Only the connectors are SVG. Straight links are a narrow fixed-size <svg>
centred between two cards; branches are a full-width one scaled uniformly, so stroke weights
stay proportional. To let arrowheads land inside the group panel, pull the panel up
and put the SVG above it in the stack.
.branch-svg { position: relative; z-index: 2; }
.group {
position: relative;
z-index: 1;
margin-top: -26px; /* slide up under the arrow tips */
padding-top: 34px; /* ...then push content back down */
}
CSS can stop CSS, but it can't stop SMIL — a prefers-reduced-motion block will
freeze the dashes and leave the dots flying. Kill those through the SVG DOM instead, with
pauseAnimations() on each root.
@media (prefers-reduced-motion: reduce) {
.track, .decision { animation: none !important; }
}
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
document.querySelectorAll('svg').forEach(s => s.pauseAnimations());
}