Image Reveal On Scroll — Clip-Path Unmask Gallery + Per-Item Background Color
Goal
Build a single full-page vertical-scroll editorial gallery of 8 tall portrait images arranged in a loosely staggered column (each image nudged to a different horizontal position so the eye zig-zags down the page). As each image scrolls into view, GSAP + ScrollTrigger unmask it top-to-bottom by morphing a clip-path polygon from a zero-height sliver at the top edge into the full rectangle (a curtain-drop reveal, power1.out, 2s). Simultaneously, entering an item tweens a fixed full-viewport background gradient to that item's own accent color, so the whole page's mood shifts color as you scroll. A fixed counter in the top-left corner reads out scroll progress as a whole-number percentage.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the single GSAP plugin ScrollTrigger. Register with gsap.registerPlugin(ScrollTrigger). No smooth-scroll library (native scroll). No SplitText, no other plugins.
Layout / HTML
Class names are load-bearing — the JS and CSS query them.
<div class="counter"><p>0</p></div>
<div class="bg-color"></div>
<div class="items">
<!-- repeat this block 8 times -->
<div class="item">
<div class="item-img">
<img src="/path/to/1.jpg" alt="" />
</div>
<div class="item-info">
<p>0.451 29 Neo-Tokyo Streetwear Ensemble</p>
</div>
</div>
...
</div>
- Exactly 8
.itemblocks inside.items, each with an.item-imgwrapper holding one<img>and an.item-infocaption below it. .counter > pstarts as the text0..bg-coloris an empty fixed layer sitting behind everything.- Item captions (small mono-ish labels, format
<decimal> <int> <Title>) in order 1→8: 0.451 29 Neo-Tokyo Streetwear Ensemble0.312 47 Cybernetic Space Suit Design0.678 15 Martian Explorer Gear Concept0.289 51 Virtual Reality Avant-Garde Apparel0.563 34 Post-Apocalyptic Survival Outfit0.409 22 Interstellar Pilot Uniform Draft0.522 40 Underwater City Diver's Attire0.601 18 Artificial Intelligence Inspired Fashion
Styling
Font family: "Neue Montreal" (a neutral grotesque). There is no @font-face in the source — it's just named on .counter and .items, so it falls back to the system sans if unavailable. That's fine; don't add a webfont.
Global:
* { margin:0; padding:0; box-sizing:border-box }.img { width:100%; height:100%; object-fit:cover }.
Fixed layers:
.counter { position:fixed; top:0; left:0; padding:2em; color:#000; font-family:"Neue Montreal" }. It holds the scroll-percentage number and stays pinned to the top-left corner over everything..bg-color { position:fixed; width:100vw; height:100vh; z-index:-1 }with initialbackground: linear-gradient(0deg, rgba(250,186,74,1) 0%, rgba(252,176,69,0) 100%)— an amber (#faba4a) glow rising from the bottom edge and fading to transparent at the top. Because it'sz-index:-1, it sits behind the page content (the page itself has no background, so this gradient is the visible backdrop).
Gallery:
.items { width:100%; height:100%; padding:4em 2em; font-family:"Neue Montreal" }..item { width:40%; margin-bottom:4em }— each item occupies 40% of the page width; the 4em bottom margin stacks them vertically with air between..item-info { padding:0.5em 0 }— the caption sits just under the image..item-img { overflow:hidden }— critical: this clips the scaled image so the clip-path reveal reads cleanly and no overflow shows..item-img img { will-change:transform; transform:scale(1.25); clip-path: polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%) }— the image is permanently zoomed to 1.25 (the scale is never animated, it just guarantees a full crop with no edge gaps), and starts fully masked by a zero-height clip polygon pinned to the top edge (all four points on the top line).
Per-item accent background (set on each .item-img via nth-child, matching the JS color array index by index):
.item:nth-child(1) .item-img { background:#faba4a }.item:nth-child(2) .item-img { background:#bb2a26 }.item:nth-child(3) .item-img { background:#7e7d65 }.item:nth-child(4) .item-img { background:#989682 }.item:nth-child(5) .item-img { background:#5e4036 }.item:nth-child(6) .item-img { background:#e33b12 }.item:nth-child(7) .item-img { background:#252d1a }.item:nth-child(8) .item-img { background:#b04c0d }
Per-item horizontal stagger (each item position:relative with a left offset, so the column weaves side to side):
.item:nth-child(1), .item:nth-child(5), .item:nth-child(8) { left:5% }.item:nth-child(3), .item:nth-child(7) { left:20% }.item:nth-child(2), .item:nth-child(4), .item:nth-child(6) { left:50% }
GSAP effect (be exact)
Two independent pieces of JS: (A) the per-item scroll-reveal + background tween, (B) the scroll-percentage counter.
A. Per-item clip-path reveal + background color tween
Define the accent color array (index-aligned with the CSS nth-child backgrounds above):
const bgColors = ["#faba4a","#bb2a26","#7e7d65","#989682","#5e4036","#e33b12","#252d1a","#b04c0d"];
const bgColorElement = document.querySelector(".bg-color");
Loop every .item with gsap.utils.toArray(".item").forEach((item, index) => { ... }). Inside, grab const img = item.querySelector(".item-img img") and create ONE gsap.fromTo per image:
gsap.fromTo(
img,
{ clipPath: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)" },
{
clipPath: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
ease: "power1.out",
duration: 2,
scrollTrigger: {
trigger: item,
start: "center bottom",
end: "bottom top",
toggleActions: "play none none none",
onEnter: () => updateBackground(bgColors[index]),
onEnterBack: () => updateBackground(bgColors[index]),
},
}
);
Exact semantics to preserve:
- Animated property: only
clipPath, frompolygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)(zero-height sliver at top — image hidden) →polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)(full rectangle — image fully shown). The bottom two points travel fromy=0%down toy=100%, so the image unmasks like a curtain dropping from the top edge to the bottom. Both polygons have 4 points so the morph interpolates cleanly. ease: "power1.out",duration: 2. This is a time-based tween triggered by scroll, NOT a scrubbed tween — there is noscrub. When the trigger fires, the 2s reveal plays on its own timeline regardless of scroll speed.- ScrollTrigger:
trigger: item;start: "center bottom"(fires when the item's vertical center reaches the viewport bottom);end: "bottom top";toggleActions: "play none none none"(play once on enter; do nothing on leave / enter-back / leave-back — so once revealed it stays revealed and never reverses). - Background callbacks:
onEnterandonEnterBackboth callupdateBackground(bgColors[index]). So scrolling down into an item, or scrolling back up into it, both retint the page to that item's accent.
Background tween helper:
function updateBackground(color) {
gsap.to(bgColorElement, {
background: `linear-gradient(0deg, ${color} 0%, rgba(252,176,69,0) 100%)`,
duration: 2,
ease: "power1.out",
});
}
It tweens .bg-color's background to a fresh vertical gradient: the current item's accent solid at the bottom (0%) fading to transparent amber rgba(252,176,69,0) at the top (100%). duration: 2, ease: "power1.out", so the color cross-fades smoothly as you move between items. (GSAP animates the gradient via its CSS plugin string interpolation.)
B. Scroll-percentage counter
Inside a DOMContentLoaded handler:
const counterElement = document.querySelector(".counter p");
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
function updateScrollPercentage() {
const scrollPosition = window.scrollY;
const scrolledPercentage = Math.round((scrollPosition / docHeight) * 100);
counterElement.textContent = `${scrolledPercentage}`;
}
window.addEventListener("scroll", updateScrollPercentage);
docHeight (total scrollable distance) is measured once on DOMContentLoaded; on every native scroll event the counter text is set to round(scrollY / docHeight * 100) — a plain integer, no % sign. It reads 0 at the top and 100 at the very bottom.
Assets / images
8 tall portrait images, 2:3 aspect ratio (source is 896×1344), one per .item. They should read as a matched editorial set: stylized fashion / concept portraits — a single figure per frame, dramatic art-directed lighting, each on a bold saturated color field, in a futuristic sci-fi-couture register (streetwear, spacesuit, explorer gear, VR apparel, survival outfit, pilot uniform, diver's attire, AI-inspired look). High contrast, punchy color per image. object-fit: cover inside the .item-img (which is 40% page width) with a permanent scale(1.25) zoom, so give margin around the subject. No brand marks, no logos.
Behavior notes
- Load state: every image starts fully masked (zero-height clip) and reveals only when its ScrollTrigger fires; the first item(s) near the top reveal almost immediately since their center is already near the viewport bottom.
- Not scrubbed / not reversible:
toggleActions: "play none none none"means the reveal plays once and holds — scrolling back up does not re-hide an image (but the background still retints viaonEnterBack). overflow:hiddenon.item-imgandwill-change:transformon the img are required for a clean clip reveal with the 1.25 zoom.- Counter caveat (keep as-is for fidelity):
docHeightis computed once atDOMContentLoaded; if images load after that and change page height, the percentage denominator won't update — this matches the original. - Light, mobile-safe, no WebGL/canvas. The layout is percentage-based; on narrow screens the 40%-wide items and
leftoffsets still apply (no dedicated media query in the source).