All components

Scroll Image Reveal Sequence

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

Open live demo ↗ Raw prompt (.md)

What it does

A long vertical scroll photo essay ('Close Range — Essay No. 04: Skin') where seven big centered chapter-headers (Ornament, Repose, Surface, Gaze, Mouth, Rain, Close) pass over a fixed 500x700 image stack of warm close-up body photographs. GSAP ScrollTrigger drives two scrubbed animations per section: a clip-path polygon that wipes each preview up into view then off again as the next chapter arrives, plus a continuous scale-to-3 zoom on the active image, ease 'none', so images grow and cross-dissolve through clip-path masks as you scroll.

How it's built

Categoryscroll
Techgsap
GSAP pluginsScrollTrigger
Complexitypage
Performance costlight
Mobile-safeyes

scroll scrolltrigger clip-path image-reveal scrub scale editorial gsap fashion

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 Image Reveal Sequence — Clip-Path Wipe Cross-Dissolve + Zoom-to-3×

Goal

Build a long, quiet vertical-scroll story on a black page. Seven giant centered word-headers ("VACCUM", "EMBER", "SCRATCH", "AZURE", "SYNTHESIS", "EUPHORIA", "THE END") are spaced far apart and scroll up the screen. Fixed dead-center sits a single 500×700 portrait "window". As each header approaches, its matching editorial photo wipes up into view through a clip-path reveal, and while it holds the frame its inner image continuously zooms from scale 1 to scale 3; then, as the next header arrives, the old photo wipes up and out the top while the next photo wipes up from the bottom — a seamless upward cross-dissolve of six images driven entirely by GSAP ScrollTrigger scrub. A small fixed line of intro copy sits behind the window and is only visible in the gaps.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the single GSAP plugin ScrollTrigger. Register with gsap.registerPlugin(ScrollTrigger). No Lenis, no SplitText, no CustomEase, no Three.js, no canvas — native browser scroll only. No build framework; Claude will scaffold Vite + npm.

Layout / HTML

Class names and IDs are load-bearing — the JS/CSS query them. One outer .container holds three stacked layers:

<div class="container">
  <!-- Layer A: fixed intro copy, sits BEHIND the image window -->
  <div class="intro-copy">
    <p>This message stays right here,</p>
    <p>no matter where you go,</p>
    <p>it won't move an inch,</p>
    <p>even if you scroll up or down.</p>
  </div>

  <!-- Layer B: the tall scrolling headers (this is what creates the scroll length) -->
  <div class="headers">
    <section id="section-1"><h1>Vaccum</h1></section>
    <section id="section-2"><h1>Ember</h1></section>
    <section id="section-3"><h1>Scratch</h1></section>
    <section id="section-4"><h1>Azure</h1></section>
    <section id="section-5"><h1>Synthesis</h1></section>
    <section id="section-6"><h1>Euphoria</h1></section>
    <section id="section-7"><h1>The End</h1></section>
    <div class="spacer"></div>
  </div>

  <!-- Layer C: fixed 500x700 image stack, sits ON TOP -->
  <div class="section-previews">
    <div class="img" id="preview-1"><img src="…img-1…" alt="" /></div>
    <div class="img" id="preview-2"><img src="…img-2…" alt="" /></div>
    <div class="img" id="preview-3"><img src="…img-3…" alt="" /></div>
    <div class="img" id="preview-4"><img src="…img-4…" alt="" /></div>
    <div class="img" id="preview-5"><img src="…img-5…" alt="" /></div>
    <div class="img" id="preview-6"><img src="…img-6…" alt="" /></div>
  </div>
</div>

Notes:

  • Exactly 7 <section> headers but only 6 #preview-N images. Section 7 ("The End") is the outro header and has no image — it only serves to trigger the conceal of the last (6th) image. Keep the header words verbatim, including the intentional spelling "Vaccum".
  • The .spacer is an empty 200px-tall div after the last section.
  • The six #preview-N divs are absolutely stacked on top of each other inside the fixed window; each .img wrapper carries the clip-path, the inner <img> is what gets scaled.

Styling

Global reset: * { margin:0; padding:0; box-sizing:border-box }.

html, body: width:100%; height:100%; background:#000; font-family:"PP Neue Montreal" — that face is not actually loaded, so fall back to a neutral grotesque sans (system-ui / Helvetica / Arial); the font is not load-bearing to the effect.

.container { width:100%; height:100% }.

img { width:100%; height:100%; object-fit:cover } (fills the window; cover-crops the portrait).

h1 (the header words): color:#fff; font-size:14vw; font-weight:400; letter-spacing:-0.025em; text-transform:uppercase; text-align:center. These are enormous — nearly full viewport width per word.

p (intro copy lines): color:#fff; font-size:14px; font-weight:500; text-transform:uppercase; text-align:center.

Layout / positioning (this geometry IS the effect's timing):

  • .intro-copy { position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); z-index:0 } — centered, pinned, low layer.
  • section { margin:150vh 0 }each header has 150vh of margin above AND below. Adjacent margins collapse, so consecutive headers are separated by ~150vh of empty scroll; this huge spacing is the runway that stretches each reveal/zoom out. There is also ~150vh before the first header.
  • .section-previews { position:fixed; width:500px; height:700px; top:50%; left:50%; transform:translate(-50%,-50%) } — a fixed 5:7 window dead-center. No z-index set, but because it is the last positioned element in DOM it paints above both the .intro-copy (z-index:0) and the in-flow scrolling headers.
  • .img { width:100%; height:100%; position:absolute; overflow:hidden; clip-path: polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%) } — every preview starts fully collapsed to a zero-height sliver at the bottom edge (invisible). overflow:hidden clips the scaled inner image.
  • .spacer { width:100%; height:200px }.

Layering recap: fixed image window (top) → scrolling giant headers (middle) → fixed intro copy (bottom). When the window is empty (all previews collapsed) you see the intro copy and a header; once a preview reveals it covers the center.

GSAP effect (be exact)

Two independent scrubbed systems run per section. System 1 scales the active image; System 2 wipes the clip-path of the image wrappers. They target different elements (System 1 → the inner #preview-N img; System 2 → the #preview-N wrapper), so they compose without conflict.

System 1 — continuous zoom-to-3× of the active image (scrub: 1)

Loop over every <section> with gsap.utils.toArray("section") (all 7). For each section at index (0-based):

const image = document.querySelector(`#preview-${index + 1} img`);
const startCondition = index === 0 ? "top top" : "bottom bottom";

gsap.to(image, {
  scrollTrigger: {
    trigger: section,
    start: startCondition,
    end: () => {
      const viewportHeight = window.innerHeight;
      const sectionBottom = section.offsetTop + section.offsetHeight;
      const additionalDistance = viewportHeight * 0.5;
      const endValue = sectionBottom - viewportHeight + additionalDistance;
      return `+=${endValue}`;
    },
    scrub: 1,
  },
  scale: 3,
  ease: "none",
});
  • Property: inner <img> scale from 1 → 3 (linear, ease:"none"), tied to scroll via scrub:1 (≈1s catch-up smoothing).
  • Start: for the FIRST section only, "top top" (section-1's top hits the viewport top); for every other section "bottom bottom" (the section's bottom reaches the viewport bottom, i.e. as it enters from below).
  • End: a *relative* distance +=endValue computed live by the callback above — sectionBottom − viewportHeight + 0.5·viewportHeight, i.e. it scrubs across roughly one-and-a-half-plus viewport heights of scroll so the zoom is slow and continuous while that photo owns the window.
  • Because #preview-7 img does not exist, the 7th iteration's tween simply has no target (harmless GSAP "target not found" — keep 7 sections / 6 images as-is).
System 2 — clip-path wipe reveal / conceal (scrub: 0.125)

Helper that lazily builds a scrubbed clip-path tween the first time its section crosses start:

function animateClipPath(sectionId, previewId, startClipPath, endClipPath,
                         start = "top center", end = "bottom top") {
  const section = document.querySelector(sectionId);
  const preview = document.querySelector(previewId);
  ScrollTrigger.create({
    trigger: section,
    start, end,
    onEnter: () => {
      gsap.to(preview, {
        scrollTrigger: { trigger: section, start, end, scrub: 0.125 },
        clipPath: endClipPath,
        ease: "none",
      });
    },
  });
}

Important faithful-reproduction detail: startClipPath is passed but never applied — the tween is a gsap.to that animates from the element's *current* live clip-path to endClipPath. In practice the live value always equals the documented startClipPath (from CSS or the previous tween's end), so an equivalent, more robust implementation is a scrubbed gsap.fromTo(preview, {clipPath:startClipPath}, {clipPath:endClipPath, ease:"none", scrollTrigger:{trigger:section,start,end,scrub:0.125}}). Either way: ease:"none", scrub:0.125.

The three clip-path polygon states (all 4-point polygons so they interpolate cleanly):

  • Collapsed-bottom (hidden): polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%) — zero-height at the bottom.
  • Full rectangle (visible): polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%).
  • Collapsed-top (hidden): polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%) — zero-height at the top.

Every reveal moves the TOP edge up (grows from bottom); every conceal moves the BOTTOM edge up (shrinks out the top) — so the whole stack reads as one continuous upward flow.

First image reveal (defaults start:"top center", end:"bottom top"):

animateClipPath("#section-1", "#preview-1",
  "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)",  // hidden → 
  "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)");     //   full rect

As section-1 travels from its top-at-viewport-center to its bottom-at-viewport-top, image 1 wipes up into full view.

Then loop for (let i = 2; i <= 7; i++) with currentSection = "#section-"+i, prevPreview = "#preview-"+(i-1), currentPreview = "#preview-"+i:

  1. Conceal the previous image (window start:"top bottom"end:"center center"):
animateClipPath(currentSection, prevPreview,
  "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",  // full rect → 
  "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)",      //   collapsed-top
  "top bottom", "center center");

As the next section enters from the viewport bottom up to its center reaching viewport center, the outgoing photo's bottom edge rises and it wipes away out the top.

  1. Reveal the current image — only while i < 7 (window start:"center center"end:"bottom top"):
if (i < 7) {
  animateClipPath(currentSection, currentPreview,
    "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)", // hidden → 
    "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",     //   full rect
    "center center", "bottom top");
}

As the same section passes from its center-at-viewport-center to its bottom-at-viewport-top, the incoming photo wipes up from the bottom into full view — overlapping the tail of the previous conceal for the cross-dissolve.

For i === 7 (the "The End" header) only the conceal runs, hiding image 6; there is no reveal (and no 7th image).

Summary of the choreography per transition
  1. Photo N sits full-frame and slowly zooms toward 3× (System 1).
  2. Section N+1 enters → photo N wipes up and out the top (System 2 conceal, scrub 0.125).
  3. Section N+1 passes center → photo N+1 wipes up from the bottom (System 2 reveal) while it begins its own zoom.

Everything is scrub-linked and fully reversible: scrolling back up plays every wipe and zoom backwards.

Assets / images

Six portrait photographs, one per header (sections 1–6), each filling the fixed 500×700 window (≈ 5:7 portrait, object-fit:cover). Treat them as one matched moody, cinematic editorial-fashion set (no logos, no real brands). In order:

  1. Advertising still-life: a translucent pale-blue rotationally-molded plastic pod/bottle floating on a soft blue-to-white gradient, tiny abstract glyph icons top-right, small caption text along the bottom.
  2. A woman outdoors at dusk in an avant-garde sculptural garment of concentric pleated fabric rings swirling around her body, muted grey-green landscape behind.
  3. Two dancers on a pale-blue background — one in a flowing sheer black dress caught mid-motion in front, a blurred white-clad figure behind; painterly long-exposure feel.
  4. A moody close-up of a woman's face at the surface of dark water, lips and chin just above the waterline with a mirror reflection, deep green-black tones.
  5. Black-and-white back view of a woman with very long straight black hair clipped down her spine by a row of metal sectioning clips, bare shoulders, plain white background.
  6. A soft-focus figure standing behind a sheer translucent fabric with arms raised, warm skin tones against a muted sage-green wall dappled with light.

Behavior notes

  • Fully scroll-scrubbed and reversible — nothing autoplays on load. At the very top the window is empty (all previews collapsed) so only the fixed intro copy and the first giant header show; the first reveal begins once you start scrolling.
  • No scroll hijacking / no pinning — the effect length comes purely from the 150vh section margins; the image window and intro copy are position:fixed while the tall headers scroll past.
  • Keep overflow:hidden on .img so the inner image scaling to 3× stays clipped to the 500×700 window.
  • Because ScrollTrigger's end for System 1 is a live callback, it recalculates on resize; call ScrollTrigger.refresh() if you add responsive breakpoints. The base build is desktop-centric (fixed 500px window); on narrow screens the window simply overflows less gracefully — no separate mobile layout is defined in the original.