Quickly navigate through the dashboard with your keyboard using the command menu.
An SVG line draws itself along scroll while a glowing node rides the path and milestone labels light up as they are reached.
Scroll Path Draw turns a section of your page into a scroll-driven roadmap: an SVG line draws itself in as you scroll, a glowing node rides along the curve, and each milestone label lights up the moment the tip reaches it. It feels like a story unspooling under your control — deliberate, kinetic, and satisfying. Reach for it on timelines, product roadmaps, "how it works" sections, or any narrative you want people to scrub through.
This gem uses GSAP with the ScrollTrigger plugin — both loaded from a CDN, so there's still no build step.
index.html, assets/style.css, assets/script.js, assets/byq-brand.css, assets/gem-frame.css, and the assets/medias/ folder.index.html in any modern browser and scroll.The page pulls [email protected] and ScrollTrigger.min.js from the jsDelivr CDN via the two <script> tags at the bottom of index.html, so you'll want an internet connection the first time. The demo also links assets/byq-brand.css, which supplies the BYQ brand tokens (colors, fonts, radii) used throughout — keep that stylesheet on the page so the tokens resolve.
The whole effect lives inside one namespaced <section>. A __track provides the scroll runway, a __stage gets pinned to the viewport, and inside sits the SVG plus a list of milestone stops.
<section class="byq-gem byq-gem--scroll-path-draw-01" aria-label="Roadmap, drawn on scroll">
<div class="byq-gem--scroll-path-draw-01__track">
<div class="byq-gem--scroll-path-draw-01__stage">
<div class="byq-gem--scroll-path-draw-01__viz">
<svg class="byq-gem--scroll-path-draw-01__svg" viewBox="0 0 900 460" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
<defs>
<linearGradient id="byqSpdStroke" x1="0" y1="0" x2="1" y2="1">
<stop class="byq-gem--scroll-path-draw-01__grad-a" offset="0%" />
<stop class="byq-gem--scroll-path-draw-01__grad-b" offset="100%" />
</linearGradient>
<filter id="byqSpdGlow" x="-60%" y="-60%" width="220%" height="220%">
<feGaussianBlur stdDeviation="7" result="b" />
<feMerge><feMergeNode in="b" /><feMergeNode in="SourceGraphic" /></feMerge>
</filter>
</defs>
<path class="byq-gem--scroll-path-draw-01__ghost" d="M 60 350 C 140 300 240 210 350 210 S 470 330 560 330 S 780 160 840 70" fill="none" />
<path class="byq-gem--scroll-path-draw-01__line" d="M 60 350 C 140 300 240 210 350 210 S 470 330 560 330 S 780 160 840 70" fill="none" stroke="url(#byqSpdStroke)" />
<circle class="byq-gem--scroll-path-draw-01__node" r="9" filter="url(#byqSpdGlow)" />
</svg>
<ol class="byq-gem--scroll-path-draw-01__stops">
<li class="byq-gem--scroll-path-draw-01__stop" style="--x: 6.67%; --y: 76.09%">
<span class="byq-gem--scroll-path-draw-01__dot" aria-hidden="true"></span>
<span class="byq-gem--scroll-path-draw-01__label">Kickoff</span>
</li>
<!-- Prototype, Beta, Launch stops follow, each with its own --x / --y -->
</ol>
</div>
</div>
</div>
</section>The two <path> elements share the exact same d geometry: __ghost is the faint full route, and __line is the one that draws in. The __node circle is the glowing dot that travels the curve. Each __stop is placed with inline --x / --y custom properties (as percentages of the viewBox), so the dots land precisely on the on-curve points of the path.
Every selector is prefixed with .byq-gem--scroll-path-draw-01 so nothing leaks into the rest of your page. The first job is the scroll runway and the frame that holds the drawing.
.byq-gem--scroll-path-draw-01__track {
height: 340vh; /* the scrub distance */
}
.byq-gem--scroll-path-draw-01__viz {
position: relative;
width: 100%;
max-width: min(68rem, calc(62vh * 900 / 460));
aspect-ratio: 900 / 460;
margin-inline: auto;
}The __track is 340vh tall — that extra height is the scroll distance you scrub through. The __viz frame is width-capped (never height-capped) and locked to the SVG's 900 / 460 ratio, which is what keeps the percentage-positioned stops aligned with the path.
The line's color comes from a gradient whose stops are set in CSS so the SVG reliably picks up the brand tokens.
.byq-gem--scroll-path-draw-01__grad-a { stop-color: var(--byq-accent-lime); }
.byq-gem--scroll-path-draw-01__grad-b { stop-color: var(--byq-accent-solar); }
.byq-gem--scroll-path-draw-01__line {
stroke-width: 4;
stroke-linecap: round;
stroke-linejoin: round;
filter: drop-shadow(0 0 6px var(--spd-glow));
}The line runs from lime to solar with a soft drop-shadow glow. Note that the CSS doesn't set stroke-dasharray here — the JavaScript measures the true path length and sets that up, which is how the "undrawn then drawn" trick works.
Each milestone starts dimmed and desaturated, then snaps to full presence when JavaScript adds is-lit.
.byq-gem--scroll-path-draw-01__stop {
position: absolute;
left: var(--x);
top: var(--y);
opacity: 0.34;
filter: saturate(0.4);
transition:
opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1),
filter 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
.byq-gem--scroll-path-draw-01__stop.is-lit {
opacity: 1;
filter: saturate(1);
}
.byq-gem--scroll-path-draw-01__stop.is-lit .byq-gem--scroll-path-draw-01__dot {
background: var(--byq-accent-lime);
border-color: var(--byq-accent-lime);
box-shadow: 0 0 0 5px rgba(197, 239, 87, 0.2), 0 0 16px var(--spd-glow);
}A separate rule on __stop--end.is-lit swaps the final "Launch" dot to --byq-accent-solar for a distinct arrival color. A prefers-reduced-motion block collapses the tall __track to height: auto and drops the transitions, so the roadmap simply appears finished.
The script measures the path, figures out where each milestone sits along it, then wires the whole thing to scroll with GSAP ScrollTrigger.
First it scopes itself: grab the gem root, bail if GSAP isn't loaded, and register the ScrollTrigger plugin.
const NS = "byq-gem--scroll-path-draw-01";
const root = document.querySelector(`.${NS}`);
if (!root || typeof gsap === "undefined") return;
if (typeof ScrollTrigger !== "undefined") gsap.registerPlugin(ScrollTrigger);Next it reads the real length of the line with getTotalLength() and samples 400 points along the curve using getPointAtLength(). For each stop, it converts the --x / --y percentages into viewBox coordinates and finds the closest sampled point — that fraction becomes the stop's threshold (how far the draw must progress before it lights up).
const total = line.getTotalLength();
const SAMPLES = 400;
const points = [];
for (let i = 0; i <= SAMPLES; i++) {
points.push(line.getPointAtLength((i / SAMPLES) * total));
}
const thresholds = stops.map((stop) => {
const x = (parseFloat(stop.style.getPropertyValue("--x")) / 100) * vb.width;
const y = (parseFloat(stop.style.getPropertyValue("--y")) / 100) * vb.height;
let best = 0, bestD = Infinity;
points.forEach((pt, i) => {
const d = (pt.x - x) * (pt.x - x) + (pt.y - y) * (pt.y - y);
if (d < bestD) { bestD = d; best = i / SAMPLES; }
});
return best;
});The render(p) function is the heart of it: given a progress value from 0 to 1, it moves the strokeDashoffset to reveal that much of the line, positions the glowing node at the matching point, and toggles is-lit on every stop whose threshold has been passed.
function render(p) {
const clamped = Math.max(0, Math.min(1, p));
line.style.strokeDashoffset = String(total * (1 - clamped));
const pt = line.getPointAtLength(total * clamped);
node.setAttribute("cx", pt.x);
node.setAttribute("cy", pt.y);
node.style.opacity = clamped > 0.005 && clamped < 0.99 ? "1" : "0";
stops.forEach((stop, i) => {
stop.classList.toggle("is-lit", clamped >= thresholds[i] - 0.01);
});
}Before any scrolling, it primes the line as fully "undrawn" by setting strokeDasharray and strokeDashoffset to the full length. If the reader prefers reduced motion (or ScrollTrigger is missing), it calls render(1) to show the finished roadmap and stops there.
line.style.strokeDasharray = `${total} ${total}`;
line.style.strokeDashoffset = String(total);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce || typeof ScrollTrigger === "undefined") {
render(1);
return;
}Finally it creates the ScrollTrigger: it pins the __stage to the viewport and scrubs from the top to the bottom of the __track. On each update it uses a short gsap.to() tween (a scrub value of 0.6 plus this power2.out easing) so the node glides smoothly between milestones instead of snapping. A resize listener calls ScrollTrigger.refresh() so everything re-measures if the box changes size.
ScrollTrigger.create({
trigger: root.querySelector(`.${NS}__track`),
start: "top top",
end: "bottom bottom",
pin: root.querySelector(`.${NS}__stage`),
scrub: 0.6,
onUpdate: (self) => {
gsap.to(state, {
p: self.progress,
duration: 0.25,
ease: "power2.out",
overwrite: true,
onUpdate: () => render(state.p),
});
},
});__label text ("Kickoff", "Prototype", "Beta", "Launch") and each <li>'s inline --x / --y percentages so the dots sit on the path. Add or remove <li> stops freely — the script re-measures thresholds automatically.d attribute on both the __ghost and __line paths (they must match). Keep the viewBox="0 0 900 460" or update the aspect-ratio and --x / --y coordinates to match.__track height: 340vh sets how long the scrub feels — raise it for a slower, more deliberate draw, lower it for a quick reveal.--byq-accent-lime and --byq-accent-solar on __grad-a / __grad-b, and the --spd-glow variable (default rgba(197, 239, 87, 0.55)) tints the glow.scrub: 0.6 and the duration: 0.25 / ease: "power2.out" tween in the ScrollTrigger onUpdate to make the node's motion tighter or lazier.__stop--up modifier to a stop to crown its label above the dot instead of below, and __stop--end to give it the solar finish color.