All components

Waabi Scroll Animation

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

Open live demo ↗ Raw prompt (.md)

What it does

Hero pinned for 3.5 viewport heights with Lenis smooth scroll and a scrubbed ScrollTrigger whose progress is mapped manually: the header slides up, the copy is revealed word by word via SplitText then fades out, and the fullscreen hero image shrinks into a 150px rounded card. A following section parallax-scrolls four image columns upward at different speeds.

How it's built

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

scroll pin scrub splittext word-reveal parallax lenis hero image-shrink

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

Waabi Scroll Animation — Pinned Hero with Manual Progress Mapping + Parallax Columns

Goal

Build a scroll-driven hero that is pinned for 3.5 viewport heights while a single scrubbed ScrollTrigger's progress is mapped by hand into four sequential phases: a centered heading slides up out of frame, a supporting line reveals word-by-word (SplitText) then fades out, and the full-screen hero image shrinks into a tiny 150 px rounded card anchored at screen center. After the hero unpins, a following section parallax-scrolls four columns of small square thumbnails upward at two different speeds. Smooth scroll via Lenis. The star effect is the single manually-mapped pinned ScrollTrigger driving all four hero phases off one progress value.

Tech

Vanilla HTML/CSS/JS with ES module imports, fresh Vite project. Install and import from npm:

  • gsap (3.x) plus the plugins ScrollTrigger and SplitText.
  • lenis — smooth scroll.
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
import Lenis from "lenis";

gsap.registerPlugin(ScrollTrigger);

Note: only ScrollTrigger is passed to registerPlugin; SplitText is used via its static factory SplitText.create(...) (importing it is enough). Wrap everything in document.addEventListener("DOMContentLoaded", () => { … }).

Layout / HTML

Three top-level <section>s. Class names and the four column IDs are load-bearing — the JS/CSS query them.

<section class="hero">
  <div class="hero-img">
    <img src="…" alt="" />
  </div>
  <div class="hero-header">
    <h1>A study of motion unfolding inside a single frame</h1>
  </div>
  <div class="hero-copy">
    <h3>The moment where stillness transforms into movement</h3>
  </div>
</section>

<section class="about">
  <div class="about-images">
    <div class="about-imgs-col" id="about-imgs-col-1">
      <div class="img"><img src="…" alt="" /></div>
      <div class="img"><img src="…" alt="" /></div>
      <div class="img"><img src="…" alt="" /></div>
      <div class="img"><img src="…" alt="" /></div>
    </div>
    <div class="about-imgs-col" id="about-imgs-col-2"> …4 .img… </div>
    <div class="about-imgs-col" id="about-imgs-col-3"> …4 .img… </div>
    <div class="about-imgs-col" id="about-imgs-col-4"> …4 .img… </div>
  </div>
  <div class="about-header">
    <h3>Fragments of motion and atmosphere gathered into a drifting collection of quiet visual moments.</h3>
  </div>
</section>

<section class="outro">
  <h3>The frame settles back into quiet stillness.</h3>
</section>

<script type="module" src="./script.js"></script>

Notes:

  • Exactly 4 .about-imgs-col columns, each with 4 .img wrappers (16 thumbnails total). Each .img holds one <img>.
  • The hero .hero-copy h3 line has 7 words — the SplitText word count matters (it drives the per-word reveal math). Use the copy above verbatim; it is neutral editorial prose, no brand names. "Waabi" is only the fictional demo name; nothing branded appears on screen.

Styling

Font (Google Fonts): Inter, full optical-size/weight axis:

@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap");

Reset & globals:

  • * { margin:0; padding:0; box-sizing:border-box; }
  • body { font-family:"Inter", sans-serif; background-color:#e3e3db; } (warm pale grey page background).
  • img { width:100%; height:100%; object-fit:cover; }
  • h1, h3 { font-weight:400; letter-spacing:-0.05rem; line-height:1; }
  • h1 { font-size:5rem; } h3 { font-size:3rem; }
  • section { position:relative; width:100%; height:100svh; }

Hero layers — .hero-img, .hero-header, .hero-copy all share:

  • position:absolute; width:100%; height:100%; will-change:transform, opacity, width, height;

.hero-img:

  • top:50%; left:50%; transform:translate(-50%, -50%); overflow:hidden;centered, so when JS shrinks its width/height it collapses toward the exact center of the viewport.

.hero-header, .hero-copy (text layers over the image):

  • padding:4rem; color:#fff; display:flex; align-items:flex-end; — white text pinned to the bottom of the frame.
  • .hero-header h1 { width:75%; }
  • .hero-copy h3 { width:50%; }

.about, .outro: display:flex; justify-content:center; align-items:center; text-align:center;

.about:

  • margin-top:275svh; — CRITICAL. This 2.75-viewport gap (plus the hero's own 100svh) is the scroll runway the pinned hero consumes; without it there is nowhere to scroll through the pinned timeline (see behavior notes).
  • .about-images { width:100%; height:100%; display:flex; justify-content:space-between; align-items:center; padding:4rem; } — the four columns spread edge to edge.
  • .about-imgs-col { position:relative; height:125%; display:flex; flex-direction:column; justify-content:space-around; will-change:transform; } — each column is taller than the viewport (125%) so its thumbnails overflow top and bottom, leaving room to drift.
  • .about-imgs-col .img { width:125px; height:125px; border-radius:10px; overflow:hidden; } — small square 1:1 thumbnails.
  • Initial column offsets (these are the CSS starting transforms GSAP animates from):
  • #about-imgs-col-1 { transform:translateY(1000px); }
  • #about-imgs-col-2 { transform:translateX(-225px) translateY(500px); }
  • #about-imgs-col-3 { transform:translateX(225px) translateY(500px); }
  • #about-imgs-col-4 { transform:translateY(1000px); }

Columns 1 & 4 start 1000 px low; the inner columns 2 & 3 start 500 px low and are nudged ∓225 px horizontally (2 left, 3 right).

  • .about-header { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:40%; } — the caption floats centered over the columns.

.outro:

  • background-color:#cecec6; (a slightly darker warm grey than the page). .outro h3 { width:35%; }

Responsive @media (max-width:1000px):

  • h1 { font-size:3rem; } h3 { font-size:2rem; }
  • .hero { z-index:2; }
  • .hero-header, .hero-copy { padding:2rem; } and .hero-header h1, .hero-copy h3 { width:100%; }
  • .about-header, .outro h3 { width:100%; padding:2rem; } .about-images { padding:2rem; }
  • .about-imgs-col .img { width:75px; height:75px; opacity:0.25; filter:saturate(0); } (thumbnails go smaller, faded and desaturated on mobile)
  • #about-imgs-col-2, #about-imgs-col-3 { transform:translateX(0px) translateY(500px); } (drop the horizontal offset on small screens)

GSAP effect (be exhaustive — this is the point)

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 event pumps ScrollTrigger.update; Lenis is driven by GSAP's ticker (time * 1000 → ms); lag smoothing off so scrub stays glued to scroll position.

SplitText — split the copy line into words
const heroCopySplit = SplitText.create(".hero-copy h3", {
  type: "words",
  wordsClass: "word",
});

Words only (no chars/lines), each word wrapped in a .word span. There is no mask option. The reveal is not a plugin animation — it is driven manually inside the ScrollTrigger onUpdate below by setting each word's opacity.

Also declare a latch flag before the ScrollTrigger:

let isHeroCopyHidden = false;
The pinned hero ScrollTrigger (the star — single trigger, manual progress mapping)
ScrollTrigger.create({
  trigger: ".hero",
  start: "top top",
  end: `+${window.innerHeight * 3.5}px`,   // pin runway = 3.5 viewport heights
  pin: true,
  pinSpacing: false,                       // no spacer — .about margin-top is the runway
  scrub: 1,                                // 1s catch-up smoothing on the scrub
  onUpdate: (self) => {
    const progress = self.progress;        // 0 → 1 across the 3.5vh pin
    // …four phases below, all keyed off `progress`…
  },
});

Inside onUpdate, progress (0→1) is sliced into four overlapping windows. Reproduce the math exactly:

Phase 1 — header slides up and out (progress 0 → 0.29):

const heroHeaderProgress = Math.min(progress / 0.29, 1);
gsap.set(".hero-header", { yPercent: -heroHeaderProgress * 100 });

.hero-header translates from yPercent:0 to yPercent:-100 (rises by its own full height, exiting the top) over the first 29 % of the scrub. gsap.set (instant, per-frame) — the scrub itself supplies the smoothing, no ease/duration here.

Phase 2 — copy reveals word-by-word (progress 0.29 → 0.50):

const heroWordsProgress = Math.max(0, Math.min((progress - 0.29) / 0.21, 1));
const totalWords = heroCopySplit.words.length;   // 7
heroCopySplit.words.forEach((word, i) => {
  const wordStart = i / totalWords;
  const wordEnd   = (i + 1) / totalWords;
  const wordOpacity = Math.max(
    0,
    Math.min((heroWordsProgress - wordStart) / (wordEnd - wordStart), 1),
  );
  gsap.set(word, { opacity: wordOpacity });
});

The reveal window is the 21 % band from 0.29 to 0.50, remapped to a local heroWordsProgress 0→1. Each word owns an equal 1/totalWords slice of that local progress and its opacity ramps 0→1 linearly across its own slice, so the 7 words illuminate left-to-right, one after another as you scroll. (Words start at opacity:0 because at progress 0 the calc yields 0 for all.)

Phase 3 — copy fades out, latched (progress crosses 0.64):

if (progress > 0.64 && !isHeroCopyHidden) {
  isHeroCopyHidden = true;
  gsap.to(".hero-copy h3", { opacity: 0, duration: 0.2 });
} else if (progress <= 0.64 && isHeroCopyHidden) {
  isHeroCopyHidden = false;
  gsap.to(".hero-copy h3", { opacity: 1, duration: 0.2 });
}

Unlike the other phases this is a one-shot tween guarded by the isHeroCopyHidden latch (not a per-frame set): once progress passes 0.64 the whole .hero-copy h3 fades to opacity:0 over 0.2s (default ease); scrolling back up under 0.64 fades it back to 1. The latch prevents the tween from re-firing every frame. Note the gap: words finish at 0.50, the line stays fully lit until 0.64, then fades.

Phase 4 — hero image shrinks to a rounded card (progress 0.71 → 1.0):

const heroImgProgress = Math.max(0, Math.min((progress - 0.71) / 0.29, 1));
const heroImgWidth        = gsap.utils.interpolate(window.innerWidth,  150, heroImgProgress);
const heroImgHeight       = gsap.utils.interpolate(window.innerHeight, 150, heroImgProgress);
const heroImgBorderRadius = gsap.utils.interpolate(0, 10, heroImgProgress);
gsap.set(".hero-img", {
  width: heroImgWidth,
  height: heroImgHeight,
  borderRadius: heroImgBorderRadius,
});

Over the final 29 % (0.71 → 1.0, remapped to local 0→1), the hero image's width shrinks from window.innerWidth → 150 px, height from window.innerHeight → 150 px, and border-radius grows 0 → 10 px — using gsap.utils.interpolate(from, to, t) for a linear lerp on each. Because .hero-img is centered with translate(-50%,-50%), it collapses toward the middle of the screen, ending as a small 150×150 rounded thumbnail. (The image lands at exactly the same visual scale/shape as the .about column thumbnails, which is the intended hand-off.)

Phase summary along the 0→1 scrub: 0–0.29 header up · 0.29–0.50 words in · 0.64 copy fades out · 0.71–1.0 image shrinks. The bands are deliberately staggered with small quiet gaps between them.

Parallax columns (four independent ScrollTriggers)

After the hero, animate the four columns upward at two speeds:

const aboutImgCols = [
  { id: "#about-imgs-col-1", y: -500 },
  { id: "#about-imgs-col-2", y: -250 },
  { id: "#about-imgs-col-3", y: -250 },
  { id: "#about-imgs-col-4", y: -500 },
];

aboutImgCols.forEach(({ id, y }) => {
  gsap.to(id, {
    y,
    scrollTrigger: {
      trigger: ".about",
      start: "top bottom",   // begins when .about enters from the bottom
      end: "bottom top",     // ends when .about leaves out the top
      scrub: true,
    },
  });
});

Each column tweens its GSAP y from its CSS starting offset to the target y, while the CSS translateX on cols 2/3 is preserved (GSAP only touches y):

  • Col 1: y 1000 → -500 (travels 1500 px up)
  • Col 2: y 500 → -250, x stays -225
  • Col 3: y 500 → -250, x stays +225
  • Col 4: y 1000 → -500 (travels 1500 px up)

So the outer columns (1 & 4) drift up nearly twice as fast/far as the inner columns (2 & 3) across the whole time .about is on screen (scrub: true, fully scroll-linked) → a layered parallax of thumbnail columns rising past the centered caption.

No CustomEase, no lerp/rAF loop of your own, no Three.js. The only easing is GSAP's scrub smoothing plus the two default-ease 0.2s copy-fade tweens; everything else is per-frame gsap.set driven by mapped progress.

Assets / images

Provided set: 8 distinct photoshero.jpg plus img1, img10, img11, img12, img13, img14, img15. This is a contemporary fashion-editorial set, not a single unified mood: it mixes a soft misty fine-art scene, clean studio portraits on bold saturated color backdrops, and several grainy motion-blur frames. Palette across the set swings from pale foggy greys/greens to vivid orange, teal and red. The hero.jpg file is landscape (~4:3); the other seven are roughly square (1:1).

  • 1 full-bleed hero imagehero.jpg (.hero-img, object-fit:cover, fills the whole viewport then shrinks to a 150 px rounded square). It shows a pale platinum-haired woman in a long white Victorian-style dress standing on a small grassy islet between two black swans on a misty pond — dominant colors are foggy pale green/grey water and reed banks, with the white dress and the two black swans as high-contrast anchors. The delivered file is landscape but the section crops it to fill; the centered standing figure survives both the full-bleed view and the final square card.
  • Thumbnail set for the 16 square slots (.img, 1:1, 125 px), 4 per column, drawn from the photos so the four columns read as one drifting editorial mood-board. The distinct subjects available:
  • the pale woman with two black swans on the misty pond (hero.jpg) — whites, blacks, foggy green.
  • a white-haired figure in a white robe seated in a decaying pastel room overflowing with white climbing flowers, a tall latticed window, and a gilt gold mirror over a marble mantel (img1.jpg) — soft whites, faded plaster, a warm gold accent.
  • a side profile of a figure in a multicolor painterly patchwork sleeveless dress against a vivid orange backdrop (img10.jpg) — bold orange field with mixed saturated brushstroke colors.
  • a brunette figure in a bright red blazer with layered gold chain necklaces against a deep teal backdrop (img11.jpg) — red-and-teal complementary contrast with a gold accent.
  • a motion-blurred figure in a grey jacket and white trousers caught mid-movement (img12.jpg) — neutral greys and off-whites, streaked by blur.
  • an extreme high-key, washed-out close-up of a pale blond face bathed in soft pink light (img13.jpg) — very light creams and pinks, nearly overexposed.
  • a dark, grainy, motion-blurred cluster of people at night (img14.jpg) — warm brown skin tones swimming in near-black shadow.
  • a motion-blurred screaming face against a vivid red background (img15.jpg) — saturated red with dark hair streaks.

Describe generically, no brands or logos. There are 8 source photos for 16 thumbnail slots, so repeat them across the columns to fill all 16.

Behavior notes

  • Trigger: scroll only. Nothing autoplays. The hero is entirely scrubbed (scrub:1) so it plays forward on scroll-down and reverses on scroll-up; parking the scroll freezes any phase mid-way. The columns are scrub:true.
  • The .about { margin-top:275svh } is the scroll runway. With pinSpacing:false no spacer is inserted, so the document's own height — hero 100svh + 275svh margin — must supply the +=window.innerHeight * 3.5 pin distance. Keep them in sync.
  • Heights use svh (100svh, margin-top:275svh) so mobile browser chrome doesn't break the full-screen layout.
  • window.innerWidth/innerHeight are read live inside onUpdate, so the image-shrink start size tracks the current viewport.
  • Desktop-first. On max-width:1000px the constrained text widths relax to 100 %, thumbnails shrink to 75 px and are faded/desaturated, and the inner columns lose their horizontal offset. The pin + scrub effect still runs.
  • No reduced-motion guard in the original.