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 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 set of fine-art body studies — close, cropped, sculpturally lit, nobody looking at the camera (no logos, no real brands). The essay is about skin, cloth and light, so the frames should feel like fragments of the same shoot. In order, matching the chapter that reveals each one:

  1. *Ornament* — an editorial portrait of hands stacked with chunky silver rings covering a face, dark wavy hair, a warm plain wall behind.
  2. *Repose* — a close-up of a woman with lowered eyes resting a hand on her bare shoulder, soft muted tones.
  3. *Surface* — a portrait fragment with draped linen over a shoulder, sculptural window light.
  4. *Gaze* — a study of clasped hands in chiaroscuro, painterly light.
  5. *Mouth* — a profile in shadow with a single beam of light crossing the face.
  6. *Close* — a study of a back and shoulder with fabric, soft directional 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.

Images

This component ships with 6 reference assets, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.

https://motionprompts.dev/c/gabrielcontassot-scroll/img-1.jpg
https://motionprompts.dev/c/gabrielcontassot-scroll/img-2.jpg
https://motionprompts.dev/c/gabrielcontassot-scroll/img-3.jpg
https://motionprompts.dev/c/gabrielcontassot-scroll/img-4.jpg
https://motionprompts.dev/c/gabrielcontassot-scroll/img-5.jpg
https://motionprompts.dev/c/gabrielcontassot-scroll/img-6.jpg

They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.

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--ground, --plate, --plaster, --dust, --plum, --hairline. 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 *, html, 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.

Adapting this to React

Everything above describes a standalone document: an ES module that runs its entire body the moment it is evaluated, reaches into the page through more than a dozen hard-coded ids, and wires up roughly twenty ScrollTrigger-linked tweens that are never expected to go away. React withdraws all three of those guarantees at once, and it does it quietly — the reveal-and-zoom sequence looks right on first load, and the damage only shows up on a second mount or a real navigation away.

Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. This module builds that trigger set unevenly: addImageScaleAnimation() creates its scale tweens up front, but animateClipPath() only creates a bare ScrollTrigger.create() up front and defers the actual scrubbed tween to onEnter — the first time the user scrolls that section into view. A double mount that doesn't undo all of it leaves two full sets of #section-N triggers reading the same scroll position, two zoom tweens racing to set the same <img>'s scale, and — because onEnter fires only once per approach — a real chance that only one of the two mounted copies ever gets its clip-path tween built at all, so the wipe silently never happens on whichever copy loses that race. None of this reproduces in a production build, because React only double-invokes effects in development; treat the cleanup as part of the effect, not an afterthought.

*(1) The entry point* — The script runs at the top level: the opening gsap.to(".intro-copy", …) fade, the addImageScaleAnimation() call, the initial animateClipPath("#section-1", "#preview-1", …) call, and the for (let i = 2; i <= totalSections; i++) loop that wires sections two through seven all execute the instant the module is evaluated. In React that is import time, before this component has rendered a single <section> or <img>, so every one of those lookups fails silently or binds to nothing. Move the whole body — all four pieces, in the order they already run — into a useEffect with an empty dependency array. Do not leave any of it in the component body: re-running addImageScaleAnimation() and the clip-path loop on every render would rebuild the entire trigger set this component owns, over and over, without ever tearing down the previous one.

*(2) Element lookups* — Every lookup here, including the ones baked into animateClipPath's default parameters, is a hard-coded id: #section-1 through #section-7, #preview-1 through #preview-6, plus the class-based .intro-copy. Give the component a root ref and scope the document.querySelector calls to it — but scoping alone doesn't close the real gap. Ids are supposed to be document-unique, and both document.querySelector("#preview-3") and the untargeted gsap.utils.toArray("section") in addImageScaleAnimation (which walks the whole document by tag, not by ref) return whichever matching element comes first in the tree, regardless of which subtree it lives in. During the StrictMode remount, or if this component is ever rendered twice on the same page, two #section-4s and two #preview-4s exist simultaneously, and every trigger from both copies binds to whichever one happened to render first. Derive the ids per instance — a useId() prefix threaded through every section-N / preview-N in the JSX and through the lookup strings that reference them — before scoping anything, or the scoping fixes a problem a second instance re-creates one level up.

*(3) Cleanup* — Wrap the effect body in a gsap.context scoped to the root ref and revert it on cleanup:

useEffect(() => {
  const ctx = gsap.context(() => {
    // the intro-copy fade, addImageScaleAnimation(), and the animateClipPath calls
  }, rootRef);
  return () => ctx.revert();
}, []);

That covers the tweens addImageScaleAnimation builds and the synchronous half of what each animateClipPath call creates — but the twelve animateClipPath calls each defer their real work: the scrubbed tween against preview is only built inside that call's onEnter, well after gsap.context's setup function has already returned. A tween created later, from inside a callback GSAP itself invokes on scroll, is not something ctx.revert() goes looking for. So a StrictMode remount can leave the first mount's lazily-built clip-path tween — and the second, nested ScrollTrigger it registers to drive its own scrub — still alive and scrubbing against a component that no longer exists, while the surviving copy builds an independent one against the same elements the next time the user scrolls past that same onEnter. The prompt above already documents the fix, for an unrelated reason: startClipPath is accepted but never actually applied, so the more robust shape is a scrubbed gsap.fromTo built directly, with no ScrollTrigger.create/onEnter indirection in between. Use that shape here — replace all twelve animateClipPath calls with direct gsap.fromTo calls, and every clip-path tween gets created synchronously inside the gsap.context callback, right alongside the scale tweens, so a single ctx.revert() catches all of it.