All components

Scroll-Powered SVG Stroke Draw

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

Open live demo ↗ Raw prompt (.md)

What it does

A thick serpentine orange SVG stroke draws itself behind the page content as you scroll, using the classic strokeDasharray/strokeDashoffset technique: the path length is measured on load and a scrubbed ScrollTrigger (ease none) animates the dashoffset to 0 across the spotlight section, with Lenis smooth scrolling.

How it's built

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

scroll svg stroke draw line scrolltrigger scrub lenis

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

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 plugin ScrollTrigger.
  • 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> d above is load-bearing — it defines the exact serpentine S-curve that loops left, right, back left, then swings down. Reproduce it verbatim, along with stroke="#FF5F0A", stroke-width="200", stroke-linecap="round", the viewBox="0 0 1378 2760" and preserveAspectRatio="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;. Their h1 is width: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:-1 puts 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 pathLength0 (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 .spotlight passes from start to end.
  • 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:-1 background 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):

  1. Person working late at a desk against a starry night blob — wall clock, flame, mug, stacked books.
  2. Character hiding under a desk hugging their knees while chat bubbles pop above the monitor.
  3. Overwhelmed person kneeling at a laptop desk clutching their head, surrounded by stacks of binders and envelopes.
  4. 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:0 on headings; .hero h1, .outro h1 → width:100%; .spotlight gap drops to 5rem; .spotlight .row stacks (flex-direction:column); the solo images and cards go width: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:200 in a 1378-wide viewBox is intentionally thick — it should read as a bold ribbon, not a hairline. No reduced-motion guard in the original.