Scroll-Powered SVG Stroke Draw — Serpentine Line Drawn On Scroll
Goal
Build an editorial scroll page whose star effect is a thick orange serpentine SVG stroke that draws itself behind the content as you scroll. The stroke lives in a background layer (z-index:-1) spanning a tall middle section; using the classic strokeDasharray / strokeDashoffset technique, the whole squiggly path starts fully hidden and is progressively "inked in" from start to finish, scrubbed 1:1 to scroll position across that section. Smooth scrolling via Lenis. Above and below the drawing section sit a full-viewport intro and outro heading.
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 (it owns the scroll driving the scrub).
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
gsap.registerPlugin(ScrollTrigger);
No SplitText, no CustomEase, no Three.js, no lerp/rAF interpolation. Run everything inside document.addEventListener("DOMContentLoaded", …).
Layout / HTML
Three top-level <section>s: an intro .hero, the tall .spotlight that holds both the content rows and the background SVG, and an .outro. Class/id names are load-bearing — the JS/CSS query them.
<section class="hero">
<h1>Designed to keep information clear and connected</h1>
</section>
<section class="spotlight">
<div class="row">
<div class="img"><img src="/img_1.svg" 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="/img_2.svg" alt="" /></div>
</div>
</div>
<div class="row">
<div class="col">
<div class="img"><img src="/img_3.svg" 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="/img_4.svg" 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:
- The
<path>dabove is load-bearing — it defines the exact serpentine S-curve that loops left, right, back left, then swings down. Reproduce it verbatim, along withstroke="#FF5F0A",stroke-width="200",stroke-linecap="round", theviewBox="0 0 1378 2760"andpreserveAspectRatio="xMidYMin meet". The tall, narrow viewBox is what makes the stroke snake vertically down the section. - The 4
.rows alternate: row 1 = one centered half-width image; row 2 = text card (left) + image (right); row 3 = image (left) + text card (right); row 4 = one centered half-width image. - Keep the demo copy verbatim (neutral SaaS-ish prose, no brands).
Styling
Reset: * { margin:0; padding:0; box-sizing:border-box; }. img { width:100%; height:100%; object-fit:cover; }.
Font (Google Fonts): Manrope, full variable weight axis, used at weight 500 throughout.
@import url("https://fonts.googleapis.com/css2?family=Manrope:wght@200..800&display=swap");
body { font-family:"Manrope", sans-serif; }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; }
Palette (CSS variables — exact hex; the stroke orange is separate and hard-coded on the SVG):
--base-100: #fafaf0; /* page background — warm off-white/cream */
--base-200: #deded5; /* hero + outro background AND the text-card background — pale grey-green */
--base-300: #0f0f0f; /* near-black — all text */
Body: background-color:var(--base-100); color:var(--base-300);. Stroke color is the orange #FF5F0A.
Layout / positioning (the pieces that make the layering + drawing work):
.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;. Theirh1iswidth:60%; text-align:center;..spotlight:position:relative; width:100%; height:100%; padding:2rem; display:flex; flex-direction:column; gap:10rem; overflow:hidden;— a tall column of rows with big 10rem gaps. Its natural (unpinned) height is the scroll runway the stroke draws across..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 solo images are half-width and 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;..spotlight .svg-path:position:absolute; top:25svh; left:50%; transform:translateX(-50%); width:90%; height:100%; z-index:-1;— this is the background drawing layer;z-index:-1puts the whole stroke behind the cards/images/text..spotlight .svg-path svg { width:100%; height:auto; }so the tall SVG scales to the section width and overflows vertically down the page.
GSAP effect (the important part — be exact)
Smooth-scroll wiring (Lenis + GSAP ticker)
Lenis is created with autoRaf:false and driven off GSAP's ticker (converting the ticker's seconds to milliseconds), with lag smoothing off so the scrub stays glued to scroll:
const lenis = new Lenis({ autoRaf: false });
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);
Measure the path, then hide it with a dash
Grab #stroke-path, bail if missing, measure its total length, and set the dash so the entire stroke is initially invisible (one dash the full length of the path, offset by that same full length — nothing is painted yet):
const path = document.getElementById("stroke-path");
if (!path) return;
const pathLength = path.getTotalLength();
path.style.strokeDasharray = pathLength;
path.style.strokeDashoffset = pathLength; // fully hidden at scroll top
The single scrubbed tween — draw the stroke
One gsap.to animates strokeDashoffset from pathLength → 0 (offset shrinking to zero reveals the stroke progressively from its start point to its end), with linear ease:"none" so the drawn length maps 1:1 to scroll progress, scrubbed by a ScrollTrigger tied to the whole .spotlight section:
gsap.to(path, {
strokeDashoffset: 0,
ease: "none",
scrollTrigger: {
trigger: ".spotlight",
start: "top top", // begins when spotlight's top reaches viewport top
end: "bottom bottom", // finishes when spotlight's bottom reaches viewport bottom
scrub: true,
},
});
- No pin. The section scrolls normally; the stroke simply inks in as the
.spotlightpasses fromstarttoend. scrub: true(no numeric smoothing) — offset is bound directly to scroll position, so it draws forward on scroll-down and un-draws on scroll-up. Parking the scroll freezes the stroke mid-draw.- Because the SVG lives in the
z-index:-1background layer, the orange line appears to weave behind the images and cards as it grows down the page.
That is the entire effect: Lenis smooth scroll + one scrubbed dashoffset tween. No timeline, no stagger, no delay, no other tweens.
Assets / images
Four square (1:1) monochrome line-art illustrations on a white background — simple black single-weight outline drawings (thin ~3px stroke, rounded caps/joins) with occasional small orange accents, in an editorial "quiet productivity" mood. Rendered with object-fit:cover; the two solo ones (rows 1 & 4) sit at half-width, the two paired ones (rows 2 & 3) fill their column. Roles / subjects (generic, no brands):
- Person working late at a desk against a starry night blob — wall clock, flame, mug, stacked books.
- Character hiding under a desk hugging their knees while chat bubbles pop above the monitor.
- Overwhelmed person kneeling at a laptop desk clutching their head, surrounded by stacks of binders and envelopes.
- Embarrassed character at a laptop covering their face while a hand points at them, heart mug beside.
If you have fewer than four, repeat; exact drawings don't matter as long as they read as a cohesive line-art set.
Behavior notes
- Trigger: scroll only. The whole animation is scroll-scrubbed — nothing autoplays. Reversible: scrolling back up un-inks the stroke.
- Fresh load (scroll at top): stroke fully hidden (
strokeDashoffset === pathLength); hero heading visible. - Responsive (
@media (max-width:1000px)):h1 → 2rem,h2 → 1.5rem,p → 1rem,letter-spacing:0on headings;.hero h1, .outro h1 → width:100%;.spotlightgap drops to5rem;.spotlight .rowstacks (flex-direction:column); the solo images and cards gowidth:100%; and the background SVG grows to.svg-path { top:15svh; width:275%; }(much wider so the stroke still reads on narrow screens). - The stroke's
stroke-width:200in a1378-wide viewBox is intentionally thick — it should read as a bold ribbon, not a hairline. No reduced-motion guard in the original.