All components

Epic Scroll Animations

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

Open live demo ↗ Raw prompt (.md)

What it does

A long scroll story where two sections (a pinned 'revealer' shape and the header-info block) are pinned with ScrollTrigger while a white cross/plus shape rotates 360deg, expands its clip-path into a full cross, drifts horizontally, then scales up 13x to wipe the screen and reveal the final content. Driven entirely by multiple scrub/onUpdate ScrollTriggers over Lenis-smoothed scroll.

How it's built

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

scroll pin scrub clip-path reveal scrolltrigger lenis editorial cinematic

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

Epic Scroll Story — Rotating Clip-Path Cross that Scales Up to Wipe the Screen

Goal

Build a long, cinematic single-page scroll story. A tiny white plus/cross shape sits over a pinned dark editorial section; as you scroll, it rotates a full 360°, its clip-path expands from a thin cross into a solid white square, it drifts horizontally from left-of-center to center, and finally scales up ~13× to white-out the entire viewport and hand off to a final white content section. Everything is driven by several scrubbed / onUpdate GSAP ScrollTriggers over Lenis-smoothed scrolling, plus two pinned sections.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin ScrollTrigger, and lenis for smooth scroll.

import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
gsap.registerPlugin(ScrollTrigger);

Run everything inside a DOMContentLoaded handler.

Layout / HTML

Class names are load-bearing — the JS and CSS query them. Stack these sections inside a single .container:

<div class="container">
  <section class="hero">
    <h1>Symphonia</h1>
  </section>

  <section class="info">
    <div class="header-rows">
      <div class="header-row"><h1>Motion</h1></div>
      <div class="header-row"><h1>Stills</h1></div>
    </div>
  </section>

  <section class="header-info">
    <p>Lorem ipsum dolor sit amet consectetur, adipisicing elit. Labore praesentium eaque ipsa illum rem cumque iusto natus, saepe provident quasi?</p>
    <div class="header-images">
      <div class="img"><img src="hero-images/img-1.jpg" alt="" /></div>
      <div class="img"><img src="hero-images/img-2.jpg" alt="" /></div>
      <div class="img"><img src="hero-images/img-3.jpg" alt="" /></div>
      <div class="img"><img src="hero-images/img-4.jpg" alt="" /></div>
    </div>
  </section>

  <section class="whitespace"></section>

  <section class="pinned">
    <div class="revealer">
      <div class="revealer-1"></div>
      <div class="revealer-2"></div>
    </div>
  </section>

  <section class="website-content">
    <h1>Lorem ipsum dolor sit amet consectetur adipisicing elit. Mollitia nobis qui corrupti sit, ad facilis, natus magnam culpa facere sunt pariatur? Voluptatum qui quis sit dolore, dolorum est neque animi!</h1>
  </section>
</div>
  • Hero title: "Symphonia". Info headers: "Motion" (top row) and "Stills" (bottom row). Paragraph and final content use placeholder lorem ipsum as above.
  • The DOM order matters: .whitespace comes before .pinned in the markup even though .pinned is absolutely positioned to overlay earlier sections (see Styling).

Styling

Fonts: Body is "PP Neue Montreal" (a clean neutral grotesque sans — any similar grotesque works as fallback). The hero title and the header-info paragraph use "PP Editorial Old" (an elegant high-contrast editorial serif — fall back to a display serif). No @font-face is required; naming the families with sane fallbacks is fine.

Global:

  • * { margin:0; padding:0; box-sizing:border-box; }
  • html, body { width:100%; height:100%; font-family:"PP Neue Montreal"; overflow-x:hidden; }
  • img { width:100%; height:100%; object-fit:cover; }
  • h1 { text-transform:uppercase; font-size:200px; font-weight:400; letter-spacing:-4px; } — yes, 200px display headings.

Sections (all full-bleed width):

  • section.hero { width:100vw; height:100vh; background:url(hero-images/hero.jpg) no-repeat 50% 50%; background-size:cover; display:flex; justify-content:center; align-items:center; }.hero h1 { font-family:"PP Editorial Old"; font-weight:lighter; color:#fff; } (white title over the dark photo).
  • section.info { width:100vw; height:150vh; background:#000; color:#fff; }. Inside, .header-row { width:100%; height:250px; padding:0 2em; display:flex; align-items:center; }; the 1st row is justify-content:flex-start (Motion pinned left), the 2nd is justify-content:flex-end (Stills pinned right).
  • .header-info { position:relative; width:100%; height:100vh; display:flex; flex-direction:column; justify-content:space-between; background:#000; color:#fff; }. .header-info p { padding:1em; font-family:"PP Editorial Old"; font-size:52px; font-weight:lighter; } (paragraph sits at the top). .header-images { width:100%; height:250px; padding:1em; display:flex; gap:1em; } (four thumbnails in a row at the bottom); .img { width:100%; height:100%; }.
  • section.whitespace { position:relative; width:100%; height:300vh; background:#000; z-index:-1; } — a tall black spacer that provides scroll runway; sits behind everything (z-index:-1).
  • section.pinned { position:absolute; top:100vh; width:100%; height:250vh; z-index:2; } — absolutely positioned so it overlays starting exactly one viewport down (right after the hero), floating above the black sections.
  • section.website-content { position:relative; width:100%; height:150vh; background:#fff; z-index:10; } — white panel on top of everything. .website-content h1 { font-size:72px; letter-spacing:0; } (dark default text on white).

The revealer (the star element) — a tiny cross built from two clipped white boxes:

  • .revealer { position:absolute; transform:translate(-50%,0%); left:35%; margin-top:325px; width:120px; height:120px; } — a 120×120 box, positioned left-of-center (35%) and pushed down 325px inside .pinned.
  • .revealer .revealer-1 { position:absolute; inset:0; width:100%; height:100%; background:#fff; clip-path:polygon(45% 0%, 55% 0%, 55% 100%, 45% 100%); } — a thin vertical white bar (only the center 45–55% column shows).
  • .revealer .revealer-2 { position:absolute; inset:0; transform:rotate(90deg); width:100%; height:100%; background:#fff; clip-path:polygon(45% 0%, 55% 0%, 55% 100%, 45% 100%); } — same clip but rotated 90°, so it reads as a thin horizontal bar. The two bars together form a small white plus (+) / cross.

Lenis boilerplate CSS (recommended): html.lenis, html.lenis body { height:auto; } .lenis.lenis-smooth { scroll-behavior:auto !important; } .lenis.lenis-stopped { overflow:hidden; }

Responsive (@media (max-width:900px)): .hero h1 { font-size:42px; letter-spacing:0; }; .header-row { height:100px; } and .header-row h1 { font-size:60px; letter-spacing:0; }; .header-info p { font-size:24px; }; .website-content h1 { font-size:48px; padding:1em; }; and re-center the revealer: .revealer { left:50% !important; width:100px; height:100px; margin-top:400px; }.

GSAP effect (be exact)

Lenis ↔ ScrollTrigger wiring
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);

Lenis drives scrolling; GSAP's ticker drives Lenis (seconds → ms, hence * 1000); every Lenis scroll calls ScrollTrigger.update.

Then create six ScrollTriggers, in this order:

1. Pin .pinned.

ScrollTrigger.create({
  trigger: ".pinned",
  start: "top top",
  endTrigger: ".whitespace",
  end: "bottom top",
  pin: true,
  pinSpacing: false,
});

The .pinned section (with the revealer) sticks to the top of the viewport from the moment its top hits the top, until the bottom of .whitespace reaches the top of the viewport. pinSpacing:false so it doesn't add layout space.

2. Pin .header-info.

ScrollTrigger.create({
  trigger: ".header-info",
  start: "top top",
  endTrigger: ".whitespace",
  end: "bottom top",
  pin: true,
  pinSpacing: false,
});

Same end point as the first pin, so the dark editorial block (paragraph + 4 thumbnails) stays frozen behind the revealer across the entire whitespace runway. pinSpacing:false.

3. Rotate the revealer 0° → 360°.

ScrollTrigger.create({
  trigger: ".pinned",
  start: "top top",
  endTrigger: ".header-info",
  end: "bottom bottom",
  onUpdate: (self) => {
    const rotation = self.progress * 360;
    gsap.to(".revealer", { rotation });
  },
});

No scrub. On every scroll update it fires a gsap.to(".revealer", { rotation }) with GSAP defaults (duration ≈ 0.5s, ease power1.out) — this gives the rotation a slight trailing/catch-up smoothing (unlike the instant tweens below). Progress is measured from .pinned top-top to .header-info bottom-bottom.

4. Expand the cross into a solid square via clip-path.

ScrollTrigger.create({
  trigger: ".pinned",
  start: "top top",
  endTrigger: ".header-info",
  end: "bottom bottom",
  onUpdate: (self) => {
    const p = self.progress;
    const clipPath = `polygon(
      ${45 - 45 * p}% ${0}%,
      ${55 + 45 * p}% ${0}%,
      ${55 + 45 * p}% ${100}%,
      ${45 - 45 * p}% ${100}%
    )`;
    gsap.to(".revealer-1, .revealer-2", { clipPath, ease: "none", duration: 0 });
  },
});

Same trigger window as #3. As progress 0 → 1, each bar's clip left edge goes 45% → 0% and right edge 55% → 100%, so both bars widen from thin strips into the full 120×120 box. Because one bar is rotated 90°, the pair morphs from a small plus into a filled white square. duration:0, ease:"none" → the clip is bound instantly to scroll (no smoothing lag).

5. Drift the revealer horizontally left:35% → 50% (toward center).

ScrollTrigger.create({
  trigger: ".header-info",
  start: "top top",
  end: "bottom 50%",
  scrub: 1,
  onUpdate: (self) => {
    const left = 35 + (50 - 35) * self.progress;
    gsap.to(".revealer", { left: `${left}%`, ease: "none", duration: 0 });
  },
});

Scrubbed with scrub:1 (≈1s smoothing). While .header-info travels from top-top to bottom-at-50%-of-viewport, the revealer's left interpolates 35% → 50%, sliding it to horizontal center. duration:0, ease:"none" inside the update, so the smoothing comes purely from scrub:1.

6. Scale the revealer up 1× → 13× to white-out the screen.

ScrollTrigger.create({
  trigger: ".whitespace",
  start: "top 50%",
  end: "bottom bottom",
  scrub: 1,
  onUpdate: (self) => {
    const scale = 1 + 12 * self.progress;
    gsap.to(".revealer", { scale, ease: "none", duration: 0 });
  },
});

Scrubbed scrub:1. Over .whitespace (from its top reaching 50% of the viewport to its bottom reaching the bottom), the now-solid white square scales 1 → 13, ballooning to cover the whole viewport — a white wipe. Because .website-content is a white panel at z-index:10, the enlarged white square hands off seamlessly into the final content section as it scrolls up.

Net choreography (top → bottom scroll)

Hero "Symphonia" → dark "Motion / Stills" headers scroll past → .header-info and .pinned both pin; the tiny white cross spins 360° while its clip-path expands into a solid square, meanwhile drifting left→center → then over the whitespace it scales ~13× into a full white-out → the white .website-content is revealed. All reversible on scroll-up (scrubbed triggers reverse; the two onUpdate-driven triggers track progress both ways).

Assets / images

  • 1 hero background: a dark, atmospheric full-bleed photograph (landscape, cropped with background-size:cover) — moody/cinematic, so the white "Symphonia" serif reads clearly over it.
  • 4 editorial thumbnails for the .header-images row: matched editorial/photographic images, each cropped into a wide cell (~250px tall, object-fit:cover), so roughly landscape 3:2–16:9 crops. They should read as one cohesive editorial set (elegant, muted).

No brand marks or logos — neutral placeholder imagery only. Total 5 images: hero.jpg plus img-1.jpg … img-4.jpg.

Behavior notes

  • Page-level component: Lenis hijacks the whole-page scroll for smoothing.
  • The tall section heights (.info 150vh, .whitespace 300vh, .pinned 250vh absolute at top:100vh) are what create the scroll runway — keep them; the ScrollTrigger start/end/endTrigger relationships depend on this stacking and on .pinned being position:absolute; top:100vh; z-index:2 while .whitespace is z-index:-1 and .website-content is z-index:10.
  • The two pins share the same end (.whitespace bottom top), so the dark block and the revealer stay locked together through the whole reveal.
  • Rotation (trigger #3) is intentionally the only motion using GSAP's default eased gsap.to (a soft ~0.5s catch-up); clip-path, left and scale all use duration:0, ease:"none" so they bind tightly to scroll (smoothing on #5/#6 comes from scrub:1).
  • On max-width:900px the revealer is re-centered (left:50%) and shrunk (100×100, margin-top:400px); headings shrink substantially. Effect still works because all revealer transforms are percentage/scale based.

</content> </invoke>