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-Nimages. 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
.spaceris an empty 200px-tall div after the last section. - The six
#preview-Ndivs are absolutely stacked on top of each other inside the fixed window; each.imgwrapper carries theclip-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:hiddenclips 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>scalefrom 1 → 3 (linear,ease:"none"), tied to scroll viascrub: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
+=endValuecomputed 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 imgdoes 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:
- 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.
- Reveal the current image — only while
i < 7(windowstart:"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
- Photo N sits full-frame and slowly zooms toward 3× (System 1).
- Section N+1 enters → photo N wipes up and out the top (System 2 conceal,
scrub 0.125). - 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:
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
150vhsection margins; the image window and intro copy areposition:fixedwhile the tall headers scroll past. - Keep
overflow:hiddenon.imgso the inner image scaling to 3× stays clipped to the 500×700 window. - Because
ScrollTrigger'sendfor System 1 is a live callback, it recalculates on resize; callScrollTrigger.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.