Web Animations Medium
SVG Workflow Animation
SVG connector paths draw themselves as you scroll using stroke-dasharray/dashoffset technique. Workflow nodes and labels appear in sequence with scroll-triggered reveals.
Open in Lab
MCP
gsap scrolltrigger svg stroke-dasharray lenis
Targets: JS HTML
Code
:root {
--bg: #070a12;
--text: #f0f4fb;
--panel: #121a2b;
--border: #263249;
--accent: #86e8ff;
--muted: #8a95a8;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
overflow-x: hidden;
}
.hero {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
background: linear-gradient(135deg, rgba(134, 232, 255, 0.04) 0%, rgba(174, 82, 255, 0.04) 100%);
}
.hero .content {
max-width: 600px;
text-align: center;
}
.eyebrow {
display: inline-block;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 1rem;
}
.hero h1 {
font-size: 3.5rem;
font-weight: 800;
line-height: 1.1;
margin-bottom: 1.5rem;
letter-spacing: -1px;
}
.subtitle {
font-size: 1.1rem;
color: var(--muted);
}
/* Workflow Section */
.workflow-section {
padding: 6rem 2rem;
background: rgba(18, 26, 43, 0.5);
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
.workflow-wrapper {
max-width: 900px;
margin: 0 auto;
position: relative;
}
#workflow-svg {
width: 100%;
height: auto;
max-height: 650px;
display: block;
overflow: visible;
}
/* SVG node text */
#workflow-svg text {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-weight: 600;
}
.node-label {
font-size: 13px;
fill: var(--text);
}
/* Connect lines — initially faint */
.connect-line {
stroke: var(--border);
opacity: 0.4;
}
/* Details Section */
.details-section {
max-width: 800px;
margin: 4rem auto;
padding: 4rem 2rem;
border: 1px solid var(--border);
border-radius: 12px;
background: rgba(18, 26, 43, 0.8);
}
.content {
max-width: 800px;
margin: 0 auto;
}
.details-section h2 {
font-size: 1.8rem;
margin-bottom: 1.5rem;
color: var(--text);
}
.details-section h3 {
font-size: 1.2rem;
margin-top: 2rem;
margin-bottom: 0.8rem;
color: var(--accent);
}
.details-section p {
font-size: 1rem;
color: var(--muted);
margin-bottom: 1.5rem;
line-height: 1.8;
}
.details-section ol {
margin-left: 1.5rem;
margin-bottom: 1.5rem;
}
.details-section ol li {
color: var(--muted);
margin-bottom: 0.8rem;
line-height: 1.7;
}
code {
background: rgba(134, 232, 255, 0.1);
color: var(--accent);
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-family: "Monaco", "Courier New", monospace;
font-size: 0.9rem;
}
.btn-back {
display: inline-block;
margin-top: 2rem;
padding: 0.8rem 1.5rem;
background: var(--accent);
color: var(--bg);
border-radius: 4px;
font-weight: 600;
text-decoration: none;
transition: all 0.3s ease;
}
.btn-back:hover {
background: rgba(134, 232, 255, 0.8);
transform: translateY(-2px);
}
/* Responsive */
@media (max-width: 768px) {
.hero h1 {
font-size: 2.5rem;
}
.workflow-section {
padding: 4rem 1rem;
}
.details-section {
margin: 2rem auto;
padding: 2rem 1.5rem;
}
.details-section h2 {
font-size: 1.4rem;
}
}
@media (max-width: 480px) {
.hero h1 {
font-size: 2rem;
}
.subtitle {
font-size: 1rem;
}
}
html.reduced-motion * {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}if (!window.MotionPreference) {
const __mql = window.matchMedia("(prefers-reduced-motion: reduce)");
const __listeners = new Set();
const MotionPreference = {
prefersReducedMotion() {
return __mql.matches;
},
setOverride(value) {
const reduced = Boolean(value);
document.documentElement.classList.toggle("reduced-motion", reduced);
window.dispatchEvent(new CustomEvent("motion-preference", { detail: { reduced } }));
for (const listener of __listeners) {
try {
listener({ reduced, override: reduced, systemReduced: __mql.matches });
} catch {}
}
},
onChange(listener) {
__listeners.add(listener);
try {
listener({
reduced: __mql.matches,
override: null,
systemReduced: __mql.matches,
});
} catch {}
return () => __listeners.delete(listener);
},
getState() {
return { reduced: __mql.matches, override: null, systemReduced: __mql.matches };
},
};
window.MotionPreference = MotionPreference;
}
function prefersReducedMotion() {
return window.MotionPreference.prefersReducedMotion();
}
function initDemoShell() {
// No-op shim in imported standalone snippets.
}
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
gsap.registerPlugin(ScrollTrigger);
initDemoShell({
title: "SVG Workflow Animation",
category: "scroll",
tech: ["gsap", "scrolltrigger", "svg", "stroke-dasharray"],
});
const lenis = new Lenis({ lerp: 0.1, smoothWheel: true });
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
const reduced = prefersReducedMotion();
if (reduced) document.documentElement.classList.add("reduced-motion");
// Hero entrance
if (!reduced) {
gsap.set(".hero .eyebrow", { opacity: 0, y: 20 });
gsap.set(".hero h1", { opacity: 0, y: 40 });
gsap.set(".hero .subtitle", { opacity: 0, y: 25 });
gsap
.timeline({ defaults: { ease: "expo.out" } })
.to(".hero .eyebrow", { opacity: 1, y: 0, duration: 0.7, delay: 0.3 })
.to(".hero h1", { opacity: 1, y: 0, duration: 0.9 }, "-=0.4")
.to(".hero .subtitle", { opacity: 1, y: 0, duration: 0.7 }, "-=0.5");
}
// SVG path drawing — core technique
const connectLines = document.querySelectorAll(".connect-line");
connectLines.forEach((path) => {
const length = path.getTotalLength();
// Set up stroke-dasharray for drawing effect
gsap.set(path, {
strokeDasharray: length,
strokeDashoffset: length,
opacity: 1,
});
if (!reduced) {
// Draw the line as user scrolls through the workflow section
gsap.to(path, {
strokeDashoffset: 0,
duration: 1,
ease: "none",
scrollTrigger: {
trigger: ".workflow-section",
start: "top 60%",
end: "bottom 80%",
scrub: 1.5,
},
});
} else {
// Show immediately for reduced motion
gsap.set(path, { strokeDashoffset: 0 });
}
});
// Node reveals — staggered sequentially as scroll progresses
const nodeOrder = [
{ id: "#node-1", label: "#label-1", delay: 0 },
{ id: "#node-2", label: "#label-2", delay: 0.15 },
{ id: "#node-3a", label: "#label-3a", delay: 0.25 },
{ id: "#node-3b", label: "#label-3b", delay: 0.25 },
{ id: "#node-4", label: "#label-4", delay: 0.4 },
];
nodeOrder.forEach(({ id, label, delay }) => {
const node = document.querySelector(id);
const labelEl = document.querySelector(label);
if (!node) return;
if (!reduced) {
gsap.to(node, {
opacity: 1,
scale: 1,
duration: 0.6,
ease: "back.out(1.7)",
scrollTrigger: {
trigger: ".workflow-section",
start: `top ${70 - delay * 20}%`,
toggleActions: "play none none reverse",
},
});
if (labelEl) {
gsap.to(labelEl, {
opacity: 1,
x: 0,
duration: 0.5,
ease: "expo.out",
scrollTrigger: {
trigger: ".workflow-section",
start: `top ${68 - delay * 20}%`,
toggleActions: "play none none reverse",
},
});
gsap.set(labelEl, { x: 10 });
}
} else {
gsap.set(node, { opacity: 1 });
if (labelEl) gsap.set(labelEl, { opacity: 1 });
}
});
// Colour pulse on nodes after reveal (ambient animation)
if (!reduced) {
ScrollTrigger.create({
trigger: ".workflow-section",
start: "top 30%",
onEnter: () => {
const circles = document.querySelectorAll(".node circle");
circles.forEach((circle, i) => {
gsap.to(circle, {
r: 40,
duration: 1.2,
ease: "sine.inOut",
yoyo: true,
repeat: -1,
delay: i * 0.3,
});
});
},
});
}
// Details section reveals
document
.querySelectorAll(
".details-section h2, .details-section h3, .details-section p, .details-section ol"
)
.forEach((el) => {
if (!reduced) {
gsap.set(el, { opacity: 0, y: 20 });
gsap.to(el, {
opacity: 1,
y: 0,
duration: 0.6,
ease: "expo.out",
scrollTrigger: { trigger: el, start: "top 75%", toggleActions: "play none none reverse" },
});
}
});
document.querySelectorAll(".details-section ol li").forEach((li, i) => {
if (!reduced) {
gsap.set(li, { opacity: 0, x: -15 });
gsap.to(li, {
opacity: 1,
x: 0,
duration: 0.5,
ease: "expo.out",
delay: i * 0.08,
scrollTrigger: { trigger: li, start: "top 75%", toggleActions: "play none none reverse" },
});
}
});
// Motion preference listener
window.addEventListener("motion-preference", (e) => {
if (e.detail.reduced) {
gsap.globalTimeline.paused(true);
} else {
gsap.globalTimeline.paused(false);
}
});<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SVG Workflow Animation — stealthisdesign</title>
<link rel="stylesheet" href="style.css">
<script type="importmap">{"imports":{"gsap":"https://esm.sh/gsap@3.13.0","gsap/ScrollTrigger":"https://esm.sh/gsap@3.13.0/ScrollTrigger","gsap/SplitText":"https://esm.sh/gsap@3.13.0/SplitText","gsap/Flip":"https://esm.sh/gsap@3.13.0/Flip","gsap/ScrambleTextPlugin":"https://esm.sh/gsap@3.13.0/ScrambleTextPlugin","gsap/TextPlugin":"https://esm.sh/gsap@3.13.0/TextPlugin","gsap/all":"https://esm.sh/gsap@3.13.0/all","gsap/":"https://esm.sh/gsap@3.13.0/","lenis":"https://esm.sh/lenis@1.1.13/dist/lenis.mjs","three":"https://esm.sh/three@0.171.0","three/addons/":"https://esm.sh/three@0.171.0/examples/jsm/"}}</script>
<style>html.lenis,
html.lenis body {
height: auto;
}
.lenis:not(.lenis-autoToggle).lenis-stopped {
overflow: clip;
}
.lenis [data-lenis-prevent],
.lenis [data-lenis-prevent-wheel],
.lenis [data-lenis-prevent-touch] {
overscroll-behavior: contain;
}
.lenis.lenis-smooth iframe {
pointer-events: none;
}
.lenis.lenis-autoToggle {
transition-property: overflow;
transition-duration: 1ms;
transition-behavior: allow-discrete;
}</style>
</head>
<body>
<section class="hero">
<div class="content">
<span class="eyebrow">Demo 40</span>
<h1>SVG Workflow Animation</h1>
<p class="subtitle">SVG paths draw themselves as you scroll, with nodes and labels appearing in sequence. A clean way to visualize processes, timelines, and workflows.</p>
</div>
</section>
<section class="workflow-section">
<div class="workflow-wrapper">
<svg id="workflow-svg" viewBox="0 0 800 600" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
<!-- Connection lines (drawn last) -->
<path class="connect-line" id="line-1-2" d="M 200 120 C 200 200 400 200 400 280" stroke="#263249" fill="none" stroke-width="2" stroke-dasharray="0" />
<path class="connect-line" id="line-2-3a" d="M 400 320 C 400 380 200 380 200 440" stroke="#263249" fill="none" stroke-width="2" stroke-dasharray="0" />
<path class="connect-line" id="line-2-3b" d="M 400 320 C 400 380 600 380 600 440" stroke="#263249" fill="none" stroke-width="2" stroke-dasharray="0" />
<path class="connect-line" id="line-3a-4" d="M 200 480 C 200 520 400 520 400 560" stroke="#263249" fill="none" stroke-width="2" stroke-dasharray="0" />
<path class="connect-line" id="line-3b-4" d="M 600 480 C 600 520 400 520 400 560" stroke="#263249" fill="none" stroke-width="2" stroke-dasharray="0" />
<!-- Node 1: Start -->
<g class="node" id="node-1" opacity="0">
<circle cx="200" cy="100" r="36" fill="#121a2b" stroke="#86e8ff" stroke-width="2"/>
<text x="200" y="96" text-anchor="middle" fill="#86e8ff" font-size="11" font-weight="600" dy="0">KICK</text>
<text x="200" y="110" text-anchor="middle" fill="#86e8ff" font-size="11" font-weight="600" dy="0">OFF</text>
</g>
<text class="node-label" id="label-1" x="260" y="105" fill="#f0f4fb" font-size="13" opacity="0">Discovery & Brief</text>
<!-- Node 2: Research -->
<g class="node" id="node-2" opacity="0">
<circle cx="400" cy="300" r="36" fill="#121a2b" stroke="#ae52ff" stroke-width="2"/>
<text x="400" y="296" text-anchor="middle" fill="#ae52ff" font-size="11" font-weight="600" dy="0">RE</text>
<text x="400" y="310" text-anchor="middle" fill="#ae52ff" font-size="11" font-weight="600" dy="0">SEARCH</text>
</g>
<text class="node-label" id="label-2" x="450" y="305" fill="#f0f4fb" font-size="13" opacity="0">Strategy & Analysis</text>
<!-- Node 3a: Design -->
<g class="node" id="node-3a" opacity="0">
<circle cx="200" cy="460" r="36" fill="#121a2b" stroke="#ffcc66" stroke-width="2"/>
<text x="200" y="456" text-anchor="middle" fill="#ffcc66" font-size="11" font-weight="600" dy="0">DE</text>
<text x="200" y="470" text-anchor="middle" fill="#ffcc66" font-size="11" font-weight="600" dy="0">SIGN</text>
</g>
<text class="node-label" id="label-3a" x="250" y="465" fill="#f0f4fb" font-size="13" opacity="0">UI / UX Design</text>
<!-- Node 3b: Dev -->
<g class="node" id="node-3b" opacity="0">
<circle cx="600" cy="460" r="36" fill="#121a2b" stroke="#ff40d6" stroke-width="2"/>
<text x="600" y="456" text-anchor="middle" fill="#ff40d6" font-size="11" font-weight="600" dy="0">DE</text>
<text x="600" y="470" text-anchor="middle" fill="#ff40d6" font-size="11" font-weight="600" dy="0">DEV</text>
</g>
<text class="node-label" id="label-3b" x="550" y="465" fill="#f0f4fb" font-size="13" opacity="0" text-anchor="end">Frontend Dev</text>
<!-- Node 4: Launch -->
<g class="node" id="node-4" opacity="0">
<circle cx="400" cy="570" r="36" fill="#121a2b" stroke="#86e8ff" stroke-width="2"/>
<text x="400" y="566" text-anchor="middle" fill="#86e8ff" font-size="11" font-weight="600" dy="0">LAUN</text>
<text x="400" y="580" text-anchor="middle" fill="#86e8ff" font-size="11" font-weight="600" dy="0">CH</text>
</g>
<text class="node-label" id="label-4" x="450" y="575" fill="#f0f4fb" font-size="13" opacity="0">Ship & Iterate</text>
</svg>
</div>
</section>
<section class="details-section">
<div class="content">
<h2>How It Works</h2>
<p>The key CSS property is <code>stroke-dasharray</code> and <code>stroke-dashoffset</code>. By setting the dashoffset to the full path length and animating it to 0, the path appears to "draw itself."</p>
<p>GSAP ScrollTrigger measures the path length with <code>path.getTotalLength()</code> at initialization, then animates the offset during scroll using <code>scrub: 1</code> for direct scroll-position linking.</p>
<h3>Step-by-Step</h3>
<ol>
<li>Get path length: <code>const len = path.getTotalLength()</code></li>
<li>Set initial state: <code>path.style.strokeDasharray = len</code>, <code>path.style.strokeDashoffset = len</code></li>
<li>Animate: <code>gsap.to(path, { strokeDashoffset: 0, scrollTrigger: { scrub: 1 } })</code></li>
<li>Reveal nodes with staggered <code>ScrollTrigger</code> at appropriate scroll positions</li>
</ol>
<a href="/" class="btn-back">Back to Showcase</a>
</div>
</section>
<script type="module" src="script.js"></script>
</body>
</html>SVG Workflow Animation
SVG connector paths draw themselves as you scroll using stroke-dasharray/dashoffset technique. Workflow nodes and labels appear in sequence with scroll-triggered reveals.
Source
- Repository:
libs-genclaude - Original demo id:
40-svg-workflow-animation
Notes
SVG connector paths draw themselves as you scroll using stroke-dasharray/dashoffset technique. Workflow nodes and labels appear in sequence with scroll-triggered reveals.