All components

Nvg8 Scroll Animation

GSAP animation component · Published 2026-07-27 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

Intro section pinned for eight viewport heights and scrubbed with Lenis smooth scroll: nine thick rounded SVG strokes draw on in a staggered order via strokeDashoffset (each with a cloned darker border path behind it for an outlined look), two large curved strokes then draw and un-draw, the section flips to a dark theme with swapped headline text at 50% progress, and the three stroke rows finally slide out with a staggered xPercent exit.

How it's built

Categoryscroll
Techgsap, lenis
GSAP pluginsScrollTrigger
Complexitypage
Performance costlight
Mobile-safeyes
Scrollhijacks scrolling

scroll pin scrub svg stroke-drawing dashoffset lenis stagger theme-switch

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

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 plugin ScrollTrigger.
  • 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", path d="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", path d="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. Same stroke-width="360", stroke-miterlimit="3.8637", stroke-linecap="round". Both curves have identical geometry, only their stroke color 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 out on .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:visible so 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 11120 -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** with pinSpacing: true, so ScrollTrigger inserts the spacer that gives the timeline its runway. scrub: 1 adds 1s of smoothing.
  • svh units (100svh, calc(100svh - 7.5px)) keep the full-screen stage stable under mobile browser chrome.
  • The end uses window.innerHeight captured at build time (no invalidateOnRefresh in the original) — desktop-first; on narrow screens the stages widen to 1000% and the headline to 90%.
  • Keep the will-change hints (transform on .svg-row, stroke-dashoffset on paths) for smooth scrubbing. No reduced-motion guard in the original.

Using this outside its demo page

This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.

  • Palette on :root--paper, --ink, --coral. These names are not namespaced and they collide: --ink is defined by 164 of the 219 components in this catalogue, --paper by 94, --muted by 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix.
  • Rules on body — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it.
  • Smooth scroll (Lenis) — this creates its own Lenis instance, and a page may only have one. If your project already runs Lenis, drop the setup shown above and reuse the existing instance, keeping the lenis.on("scroll", ScrollTrigger.update) wiring once. Two instances fight over the same scroll and stutter visibly, with no error in the console.

Adapting this to React

Everything above describes a standalone document: a script that waits for DOMContentLoaded, wires Lenis into GSAP's ticker, mutates the SVG markup in three passes, and only then builds the one pinned, scrubbed timeline — and never has to undo any of it. React withdraws that guarantee, and this component's own DOM-prep passes are exactly where the withdrawal bites hardest, because two of the three write to the DOM directly (cloneNode, insertBefore, setAttribute) rather than through GSAP, so nothing about gsap.context reaches them.

Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before the screen updates — and the mounted JSX subtree is not torn down between those two runs, only the effect body re-executes. That distinction is what turns this component's prep passes into a real bug rather than a theoretical one. Pass 1 walks every .svg-row svg path and inserts a .border-path sibling in front of it; run twice without removing what the first run inserted, and the second pass clones the clones — the eleven original paths (nine bars, two curves) become twenty-two live elements after the first pass and grow again after the second, some now #0f0f0f-on-#0f0f0f. Pass 2 reads each curve <svg>'s current viewBox, subtracts from y and adds to height; it has no baseline to return to, so a second, uncleaned run subtracts and adds a second time onto numbers the first run already shifted, and the round caps clip or overshoot depending on which pass you're looking at. Both of these are visible on the very first StrictMode double-invoke in dev, not on some later remount.

*(1) The entry point* — the whole body, from new Lenis() through the final tl.to(...) that slides the rows off, runs inside document.addEventListener("DOMContentLoaded", ...). That event has already fired by the time a React component mounts, so the listener registers and is never called: no Lenis instance, no border paths, no pinned .intro, nothing to scroll through, and no error pointing at why. Delete the listener and move its entire body into a useEffect with an empty dependency array. gsap.registerPlugin(ScrollTrigger) can stay at module scope — re-registering per mount is harmless but pointless.

*(2) Element lookups* — this script leans on id selectors more than most in this catalogue: nine bar ids (svg-top-1 through svg-bottom-3) and two curve ids, each read through a template literal (` #${id} path ), plus .intro, .svg-container-2 svg and .svg-container .svg-row. id is a document-global attribute, and during the StrictMode double-mount two transient copies of this markup carry the same eleven ids at once, so document.querySelectorAll("#svg-top-1 path") matches whichever copy's paths happen to be in the document — live or on its way out. Give the wrapping <section class="intro"> a root ref and run every one of these lookups, the #${id} ones included, on rootRef.current instead of document. Scoping the *call*, not the selector text, is what fixes it: Element.querySelectorAll only searches that element's own descendants, so rootRef.current.querySelectorAll("#svg-top-1 path") still resolves correctly even while a second, unmounting #svg-top-1` sits elsewhere in the tree.

*(3) Cleanup* — wrap the Lenis wiring, the three DOM-prep passes, the timeline and the ScrollTrigger.create call in one gsap.context scoped to the root ref, and capture what the prep passes are about to touch before the factory runs:

useEffect(() => {
  const root = rootRef.current;
  const borderPaths = [];
  const originalViewBoxes = new Map(
    Array.from(root.querySelectorAll(".svg-container-2 svg"), (svg) => [svg, svg.getAttribute("viewBox")]),
  );

  const lenis = new Lenis();
  const onTick = (time) => lenis.raf(time * 1000);

  const ctx = gsap.context(() => {
    lenis.on("scroll", ScrollTrigger.update);
    gsap.ticker.add(onTick);
    gsap.ticker.lagSmoothing(0);

    root.querySelectorAll(".svg-row svg path").forEach((originalPath) => {
      const borderPath = originalPath.cloneNode(true);
      /* recolor, widen, insertBefore — exactly as above */
      borderPaths.push(borderPath);
    });

    /* Pass 2 (grow the curve viewBoxes), Pass 3 (prime the dash offsets), the
       timeline, and ScrollTrigger.create({ trigger: root.querySelector(".intro"), ... })
       go here, unchanged */
  }, rootRef);

  return () => {
    ctx.revert();
    gsap.ticker.remove(onTick);
    lenis.destroy();
    borderPaths.forEach((el) => el.remove());
    originalViewBoxes.forEach((viewBox, svg) => svg.setAttribute("viewBox", viewBox));
  };
}, []);

ctx.revert() covers exactly what GSAP created inside that factory: the pinned ScrollTrigger — and with it the pin-spacer it inserted around .intro — plus every tween on the timeline: the nine staggered stroke reveals, the two curve draw-then-undraw pairs, and the row slide-off. It does not reach the four lines above it in the returned cleanup, and none of those four are GSAP's doing. gsap.ticker.add(onTick) is a ticker subscription, not a tween or a trigger, so an unreverted context leaves it firing into whatever lenis used to be; remove onTick before calling lenis.destroy(), not after, or a tick that lands between the two calls calls .raf() on an instance that no longer exists. The .border-path clones and the rewritten viewBox strings are plain DOM writes from cloneNode/insertBefore/setAttribute — invisible to a context that only tracks GSAP's own output — so they need the explicit removal and restore shown above, using exactly the list and the map the effect built before the factory ran. Skip either one and the next mount compounds it: more phantom border paths, a viewBox drifting further from its authored values.