Draw SVG Stroke On Scroll — Scroll-Scrubbed Self-Drawing Line
Goal
Build an editorial scroll page whose star effect is a tall, winding, thick orange stroke that draws itself on from top to bottom as you scroll. A single inline <svg> path — a chunky, rounded, S-shaped ribbon — sits behind a centered column of content (image rows and text cards) at z-index: -1. Using the classic strokeDasharray/strokeDashoffset line-drawing trick, one scrubbed GSAP ScrollTrigger ties the stroke's strokeDashoffset directly to scroll progress through the middle section, so the orange line appears to snake into existence behind the content and un-draws when you scroll back up. Smooth scroll via Lenis. That's the whole trick — no timeline, no stagger, one property.
Tech
Vanilla HTML/CSS/JS with ES module imports, in a fresh Vite project. Install and import from npm:
gsap(3.x) plus the pluginScrollTrigger.lenis— smooth scroll, wired into GSAP's ticker andScrollTrigger.update.
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
gsap.registerPlugin(ScrollTrigger);
Run everything inside document.addEventListener("DOMContentLoaded", …). No other plugins, no framework.
Layout / HTML
Three top-level <section>s: a full-viewport hero, a tall spotlight (the content column + the drawing SVG), and a full-viewport outro. Class names are load-bearing.
<section class="hero">
<h1>Designed to keep information clear and connected</h1>
</section>
<section class="spotlight">
<div class="row">
<div class="img"><img src="…" alt="" /></div>
</div>
<div class="row">
<div class="col">
<div class="card">
<h2>A cleaner way to handle incoming updates</h2>
<p>
Instead of showing every message or notification instantly, the
app groups related items and presents them in an organized panel.
It keeps your workspace calm, even when activity spikes.
</p>
</div>
</div>
<div class="col">
<div class="img"><img src="…" alt="" /></div>
</div>
</div>
<div class="row">
<div class="col">
<div class="img"><img src="…" alt="" /></div>
</div>
<div class="col">
<div class="card">
<h2>Built for increasing information demands</h2>
<p>
Whether it is files, notes, or incoming messages, the app sorts
and prioritizes items automatically. It prevents clutter and helps
maintain clarity during busy periods.
</p>
</div>
</div>
</div>
<div class="row">
<div class="img"><img src="…" alt="" /></div>
</div>
<div class="svg-path">
<svg
viewBox="0 0 1378 2760"
fill="none"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMin meet"
>
<path
id="stroke-path"
d="M639.668 100C639.668 100 105.669 100 199.669 601.503C293.669 1103.01 1277.17 691.502 1277.17 1399.5C1277.17 2107.5 -155.332 1968 140.168 1438.5C435.669 909.002 1442.66 2093.5 713.168 2659.5"
stroke="#FF5F0A"
stroke-width="200"
stroke-linecap="round"
/>
</svg>
</div>
</section>
<section class="outro">
<h1>Clearer organization ready for whatever comes next</h1>
</section>
<script type="module" src="./script.js"></script>
Notes on structure:
- The spotlight has exactly 4
.rows. Rows 1 and 4 are a single centered.img(constrained to 50% width). Rows 2 and 3 are two-column: an image column and a text.cardcolumn, in alternating order (row 2 = card left / image right; row 3 = image left / card right). - The
.svg-pathis the last child of.spotlight, absolutely positioned and pushed behind everything withz-index: -1. Keep the SVG markup exactly as above — theviewBox, pathd,strokecolor,stroke-width: 200, andstroke-linecap: rounddefine the whole visual. The path must carryid="stroke-path"(the JS queries it). - Copy is neutral demo prose about an app that organizes information — use it verbatim; no brands.
Styling
Font (Google Fonts): Manrope, variable weight axis 200..800:
@import url("https://fonts.googleapis.com/css2?family=Manrope:wght@200..800&display=swap");
Palette (CSS variables — exact hex):
--base-100: #fafaf0; /* warm off-white — page background */
--base-200: #deded5; /* pale grey-green — hero/outro background + card background */
--base-300: #0f0f0f; /* near-black — body text */
The stroke's orange #FF5F0A lives on the SVG path itself (not a CSS var).
Global / reset:
* { margin:0; padding:0; box-sizing:border-box; }body { font-family:"Manrope", sans-serif; background-color:var(--base-100); color:var(--base-300); }img { width:100%; height:100%; object-fit:cover; }h1, h2 { font-weight:500; line-height:1.1; }h1 { font-size:4rem; letter-spacing:-0.1rem; }h2 { font-size:2.5rem; letter-spacing:-0.075rem; }p { font-size:1.125rem; font-weight:500; }
Sections:
.hero, .outro { position:relative; width:100%; height:100svh; padding:2rem; background-color:var(--base-200); display:flex; justify-content:center; align-items:center; overflow:hidden; }with.hero h1, .outro h1 { width:60%; text-align:center; }..spotlight { position:relative; width:100%; height:100%; padding:2rem; display:flex; flex-direction:column; gap:10rem; overflow:hidden; }— the big10remvertical gap between rows is what gives the stroke room to wander between content..spotlight .row { display:flex; justify-content:center; gap:2rem; }.spotlight .row .col { flex:1; display:flex; flex-direction:column; justify-content:center; }.spotlight .row:nth-child(1) .img, .spotlight .row:nth-child(4) .img { width:50%; }— the two full-width rows keep their single image at half width, centered..spotlight .card { width:75%; margin:0 auto; padding:3rem; background-color:var(--base-200); border-radius:1rem; display:flex; flex-direction:column; gap:1rem; }
The drawing layer — critical positioning:
.spotlight .svg-path {
position: absolute;
top: 25svh;
left: 50%;
transform: translateX(-50%);
width: 90%;
height: 100%;
z-index: -1; /* sits BEHIND the content rows */
}
.spotlight .svg-path svg {
width: 100%;
height: auto; /* tall SVG overflows its container downward */
}
GSAP effect (the important part — be exhaustive)
Smooth-scroll wiring (Lenis + GSAP ticker)
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => {
lenis.raf(time * 1000);
});
gsap.ticker.lagSmoothing(0);
Lenis' scroll events drive ScrollTrigger.update; Lenis itself is stepped by GSAP's ticker (time * 1000 converts ticker seconds → Lenis' expected ms); lag smoothing is off so the scrub stays glued to scroll position.
Prime the dash for line-drawing
Grab the path, measure it, and set both dash values to the full length so the stroke starts fully hidden:
const path = document.getElementById("stroke-path");
const pathLength = path.getTotalLength();
path.style.strokeDasharray = pathLength; // one dash exactly as long as the whole path
path.style.strokeDashoffset = pathLength; // offset by the full length → nothing visible yet
With dasharray === dashoffset === pathLength, the single dash is pushed entirely "off the end," so at rest the stroke is invisible. Animating strokeDashoffset down to 0 slides that dash into view, drawing the line progressively from its start point to its end point.
The one scrubbed tween
A single gsap.to animates strokeDashoffset from its primed value (pathLength) to 0, linearly (ease: "none") and fully scrubbed to the spotlight section's scroll range:
gsap.to(path, {
strokeDashoffset: 0,
ease: "none",
scrollTrigger: {
trigger: ".spotlight",
start: "top top", // begin drawing when the spotlight's top hits the viewport top
end: "bottom bottom", // finish when the spotlight's bottom hits the viewport bottom
scrub: true, // dashoffset is tied 1:1 to scroll progress
},
});
Behavior: as you scroll through the entire height of .spotlight, strokeDashoffset interpolates linearly from pathLength → 0, so the winding orange ribbon draws on in exact lockstep with scroll — halfway through the section the line is half-drawn; scroll back up and it un-draws. ease: "none" is essential so drawing speed is uniform with scroll (no easing curve). No pin, no pinSpacing, no timeline, no stagger — this lone property tween is the entire effect.
No SplitText, no CustomEase, no lerp/rAF interpolation, no Three.js. Only Lenis-smoothed scroll and one scrubbed strokeDashoffset tween.
Assets / images
Four monochrome (black / white / grey) vector cartoon illustrations on a white background — flat, thin-stroke line-art style with no color, so the single orange stroke is the only saturated element on the page. Roles / aspect:
- Image 1 — square (~1:1, e.g. 500×500), first row, centered at 50% width.
- Image 2 — square (~1:1), row 2, right column beside a text card.
- Image 3 — landscape (~3:2, e.g. 750×500), row 3, left column beside a text card.
- Image 4 — square (~1:1), last row, centered at 50% width.
They render with object-fit: cover, so exact framing is forgiving. No brands or logos. If you have fewer than four, repeat.
Behavior notes
- Trigger: scroll only, fully scrubbed. Nothing autoplays; parking the scroll freezes the stroke mid-draw, and reversing scroll un-draws it.
- Responsive (
@media (max-width:1000px)): drop letter-spacing to0;h1 → 2rem,h2 → 1.5rem,p → 1rem;.hero h1, .outro h1 → width:100%;.spotlightgap →5rem; rows stack vertically (.spotlight .row { flex-direction:column; }); the rows-1&4 image and the cards go full width; and the drawing layer shifts to.spotlight .svg-path { top:15svh; width:275%; }so the wide stroke still spans the narrow column. - The stroke's
stroke-width:200(in the 1378×2760 viewBox) reads as a bold ribbon, not a hairline;stroke-linecap:roundgives it rounded ends. Keep these — a thin stroke would change the whole look. - No reduced-motion guard in the original.