Nvg8 Scroll Animation — Pinned Scroll-Scrubbed SVG Stroke Reveal
Goal
Build a single full-screen intro that is pinned for eight viewport heights and scrubbed with Lenis smooth scroll, driving one GSAP timeline. As you scroll: nine thick, rounded, outlined SVG bars draw themselves on (via strokeDashoffset) in a deliberately shuffled order across three horizontal rows; then two big curved strokes draw on and then un-draw; at the timeline's midpoint the whole section flips from a warm light theme to a dark theme with a swapped headline; and finally the three rows of bars slide off to the right with a stagger to reveal a plain outro section. Every bar has a darker cloned "border" path behind it, so each colored stroke reads as an outlined shape. The star effect is the staggered scroll-scrubbed stroke-drawing choreography.
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.
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
gsap.registerPlugin(ScrollTrigger);
No other plugins, no framework. Run everything inside document.addEventListener("DOMContentLoaded", …).
Layout / HTML
Two <section>s: .intro (the pinned stage) and .outro. Class names and SVG ids are load-bearing — the JS queries them.
<section class="intro">
<h1 class="intro-header-in">Scroll down to watch calm quietly unravel</h1>
<h1 class="intro-header-out">Welcome back, things have shifted again</h1>
<!-- Three rows of thick horizontal bars -->
<div class="svg-container">
<div class="svg-row">
<svg id="svg-top-1" viewBox="0 0 3360 360" …>…</svg>
<svg id="svg-top-2" viewBox="0 0 3360 360" …>…</svg>
<svg id="svg-top-3" viewBox="0 0 3360 360" …>…</svg>
</div>
<div class="svg-row">
<svg id="svg-middle-1" viewBox="0 0 3360 360" …>…</svg>
<svg id="svg-middle-2" viewBox="0 0 3360 360" …>…</svg>
<svg id="svg-middle-3" viewBox="0 0 3360 360" …>…</svg>
</div>
<div class="svg-row">
<svg id="svg-bottom-1" viewBox="0 0 3360 360" …>…</svg>
<svg id="svg-bottom-2" viewBox="0 0 3360 360" …>…</svg>
<svg id="svg-bottom-3" viewBox="0 0 3360 360" …>…</svg>
</div>
</div>
<!-- Two overlapping curved strokes, in the MIDDLE of a 3-row container -->
<div class="svg-container-2">
<div class="svg-row"></div>
<div class="svg-row">
<svg id="svg-curve-1" viewBox="0 0 2248 1112" …>…</svg>
<svg id="svg-curve-2" viewBox="0 0 2248 1112" …>…</svg>
</div>
<div class="svg-row"></div>
</div>
</section>
<section class="outro">
<h1>This is the outro, pretend it’s profound</h1>
</section>
<script type="module" src="./script.js"></script>
SVG geometry (exact)
Every SVG has width/height matching its viewBox, fill="none", and one <path>.
- The nine horizontal bars (
svg-top-*,svg-middle-*,svg-bottom-*):viewBox="0 0 3360 360", pathd="M180 180H3180"— a straight horizontal line at mid-height from x=180 to x=3180. Stroke attributes:stroke-width="360",stroke-miterlimit="3.8637",stroke-linecap="round". With the 360-wide round-capped stroke the line reads as a fat pill/bar spanning nearly the full 3360 width. - The two curves (
svg-curve-1,svg-curve-2):viewBox="0 0 2248 1112", pathd="M180 180.538C1512.01 180.54 1718.64 133.099 2067.5 931.594"— one cubic bézier that runs nearly flat across the top then hooks steeply down on the right. Samestroke-width="360",stroke-miterlimit="3.8637",stroke-linecap="round". Both curves have identical geometry, only theirstrokecolor differs.
Per-element stroke colors (the stroke attribute on each path)
svg-top-1 #FF6D38 (orange) svg-middle-1 #7A78FF (indigo)
svg-top-2 #C6FE69 (lime) svg-middle-2 #B9DDFD (pale blue)
svg-top-3 #7A78FF (indigo) svg-middle-3 #C6FE69 (lime)
svg-bottom-1 #FFC412 (gold) svg-curve-1 #ffc412 (gold)
svg-bottom-2 #FF6D38 (orange) svg-curve-2 #FF6D38 (orange)
svg-bottom-3 #B9DDFD (pale blue)
Copy is neutral (no brands). "Nvg8" is only the fictional demo name; keep the three headline strings verbatim.
Styling
Font (Google Fonts): Barlow Condensed, import the full weight range (100–900, roman + italic).
Reset: * { margin:0; padding:0; box-sizing:border-box; }. body { font-family:"Barlow Condensed", sans-serif; overflow-x:hidden; }.
Type: h1 { text-transform:uppercase; font-size:4rem; font-weight:800; line-height:0.85; }.
Sections and theme:
section { position:relative; width:100%; height:100svh; overflow:hidden; background-color:#e3e3db; }— the light warm-grey base theme.section h1 { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:50%; text-align:center; }— headline centered over everything.h1.intro-header-out { display:none; }— the "out" headline is hidden by default.- Dark-theme override, toggled by a class
outon.intro:
``css .intro.out { background-color:#141414; color:#fff; } .intro.out h1.intro-header-in { display:none; } .intro.out h1.intro-header-out { display:block; } ` So flipping .intro.out` on both swaps the background/text color to dark and swaps which headline is visible.
Stroke stage layout:
.svg-container, .svg-container-2 { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:300%; height:calc(100svh - 7.5px); display:flex; flex-direction:column; justify-content:center; align-items:center; }— both stages are 300% of viewport width (so the bars overflow far past both edges and only their middle is visible) and centered..svg-row { position:relative; flex:1; width:100%; height:100%; will-change:transform; }— three equal-height rows stacked vertically..svg-row svg { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:100%; height:100%; overflow:visible; }— every bar fills its row;overflow:visibleso the fat stroke and its border aren't clipped..svg-container-2 svg { position:absolute; top:0%; left:40%; height:310%; object-fit:contain; transform:translate(-50%,-1%); }— the two curves are pulled left of center and blown up to 310% row height so the bézier sweeps across a big diagonal region..svg-row svg path { will-change:stroke-dashoffset; }.
Responsive: @media (max-width:1000px) { section h1 { width:90%; } .svg-container, .svg-container-2 { width:1000%; } } — on narrow screens the stages balloon to 1000% width so the visible slice of the bars stays proportionally similar.
GSAP effect (the important part — be exact)
Smooth-scroll wiring (Lenis ↔ GSAP)
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
DOM prep (runs before building the timeline — three passes)
Pass 1 — clone a dark "border" path behind every stroke. Iterate every .svg-row svg path (this matches all 9 bars AND both curves). Clone each path, recolor the clone to #0f0f0f, make it originalWidth + 10 wide (so 370 for all of them), tag it .border-path, and insert it before its original inside the same <svg>:
document.querySelectorAll(".svg-row svg path").forEach((originalPath) => {
const borderPath = originalPath.cloneNode(true);
const originalWidth = parseInt(originalPath.getAttribute("stroke-width"));
borderPath.setAttribute("stroke", "#0f0f0f");
borderPath.setAttribute("stroke-width", originalWidth + 10);
borderPath.classList.add("border-path");
originalPath.parentElement.insertBefore(borderPath, originalPath);
});
Because the clone sits behind the colored path but is 10px wider, a thin near-black outline shows around every bar/curve. Each #svg-… now contains two paths (border + colored), and every subsequent #id path query animates both together.
Pass 2 — grow the curve viewBoxes vertically. For each .svg-container-2 svg, expand the viewBox by 10 up top and 20 in height so the fatter border stroke's round cap isn't clipped:
document.querySelectorAll(".svg-container-2 svg").forEach((svg) => {
const [x, y, width, height] = svg.getAttribute("viewBox").split(" ").map(Number);
svg.setAttribute("viewBox", `${x} ${y - 10} ${width} ${height + 20}`);
});
(So 0 0 2248 1112 → 0 -10 2248 1132.)
Pass 3 — prime dash-drawing on every path. For each .svg-row svg path (now including the border clones), set strokeDasharray and strokeDashoffset both equal to the path's own length, so all strokes start fully hidden:
document.querySelectorAll(".svg-row svg path").forEach((path) => {
const pathLength = path.getTotalLength();
path.style.strokeDasharray = pathLength;
path.style.strokeDashoffset = pathLength;
});
The pinned, scrubbed ScrollTrigger
One timeline tl = gsap.timeline(), driven by one ScrollTrigger on .intro:
const introSection = document.querySelector(".intro");
ScrollTrigger.create({
trigger: introSection,
start: "top top",
end: `+=${window.innerHeight * 8}px`, // pinned across EIGHT viewport heights
pin: true,
pinSpacing: true,
scrub: 1, // 1s catch-up smoothing on the scrub
animation: tl,
onUpdate: (self) => {
if (self.progress >= 0.5) introSection.classList.add("out");
else introSection.classList.remove("out");
},
});
The theme flip is not a tween — it's driven purely by onUpdate: crossing 50% scroll progress toggles the .out class (dark bg + swapped headline), and scrolling back up removes it.
Timeline contents (exact order, positions, durations, eases)
All positions below are absolute times (in timeline "seconds") passed as the 3rd arg to tl.to(...); the scrub maps the whole timeline linearly onto the 8-viewport scroll runway.
1) Nine bars draw on, in a shuffled order, each staggered by 0.3. Loop this exact strokeRevealOrder and place tween i at i * 0.3:
const strokeRevealOrder = [
"svg-top-1", "svg-bottom-1", "svg-middle-1",
"svg-top-2", "svg-bottom-2", "svg-middle-2",
"svg-top-3", "svg-middle-3", "svg-bottom-3",
];
strokeRevealOrder.forEach((id, index) => {
tl.to(document.querySelectorAll(`#${id} path`),
{ strokeDashoffset: 0, duration: 1.5, ease: "power2.out" },
index * 0.3);
});
So the reveal times are: top-1 @0.0, bottom-1 @0.3, middle-1 @0.6, top-2 @0.9, bottom-2 @1.2, middle-2 @1.5, top-3 @1.8, middle-3 @2.1, bottom-3 @2.4. Each bar draws left→right (its path starts at x=180) over 1.5s with power2.out. The order deliberately fills column 1 (top→bottom→middle), then column 2, then column 3 — a scattered, non-linear fill.
2) Two curves draw on, then un-draw. curveStartTime = 5 * 0.3 + 0.3 = 1.8. For each of ["svg-curve-1","svg-curve-2"] at index, with curveStartAt = 1.8 + index * 1:
["svg-curve-1", "svg-curve-2"].forEach((id, index) => {
const paths = document.querySelectorAll(`#${id} path`);
const pathLength = paths[0].getTotalLength();
const curveStartAt = 1.8 + index * 1;
tl.to(paths, { strokeDashoffset: 0, duration: 1, ease: "power2.out" }, curveStartAt);
tl.to(paths, { strokeDashoffset: -pathLength, duration: 1.5, ease: "power2.inOut" }, curveStartAt + 1);
});
- curve-1: draws on @1.8 (dur 1,
power2.out, offset → 0), then un-draws @2.8 (dur 1.5,power2.inOut, offset →-pathLength, so the tail wipes off the far end). - curve-2: draws on @2.8, un-draws @3.8.
Because the two curves overlap in place (different colors) and are offset by 1s, they read as a single curved stroke that sweeps on and then peels away.
3) The three bar-rows slide off to the right (the exit). Target only .svg-container .svg-row (the 3 horizontal rows, NOT the curve container), placed at ">-0.5" (start 0.5s before the previous tween's end, i.e. ~4.8):
tl.to(document.querySelectorAll(".svg-container .svg-row"),
{ xPercent: 100, duration: 2, ease: "power3.inOut", stagger: 0.15 },
">-0.5");
Each row translates xPercent: 100 (fully off to the right), staggered by 0.15, easing power3.inOut — the whole grid of bars glides out to unveil the base background, right as the scroll approaches the end of the pinned runway.
No SplitText, no CustomEase, no lerp/rAF interpolation, no Three.js. The entire effect is: dash-offset stroke drawing + one class-toggle theme flip + an xPercent exit, all inside one pinned, scrubbed timeline.
Assets / images
None. There are no raster images — every visual is an inline <svg> path (nine fat horizontal bars + two curved strokes), colored via the stroke attribute and outlined by the cloned dark border paths described above.
Behavior notes
- Trigger: scroll only, fully scrubbed. Parking the scroll freezes the choreography mid-draw; scrolling up reverses everything, including un-toggling the dark theme below 50% progress.
- **Pinned for
window.innerHeight * 8** withpinSpacing: true, so ScrollTrigger inserts the spacer that gives the timeline its runway.scrub: 1adds 1s of smoothing. svhunits (100svh,calc(100svh - 7.5px)) keep the full-screen stage stable under mobile browser chrome.- The
enduseswindow.innerHeightcaptured at build time (noinvalidateOnRefreshin the original) — desktop-first; on narrow screens the stages widen to1000%and the headline to90%. - Keep the
will-changehints (transformon.svg-row,stroke-dashoffseton paths) for smooth scrubbing. No reduced-motion guard in the original.