All components

Image Reveal On Scroll

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

Open live demo ↗ Raw prompt (.md)

What it does

A staggered grid of tall portrait images that each unmask as they enter the viewport: GSAP animates a clip-path polygon from a zero-height sliver to a full rectangle (power1.out, 2s) driven by ScrollTrigger, while onEnter/onEnterBack tween the fixed background gradient to the current item's accent color and a fixed counter tracks scroll percentage.

How it's built

Categoryscroll
Techgsap
GSAP pluginsScrollTrigger
Complexitypage
Performance costlight
Mobile-safeyes

scroll clip-path image-reveal gsap scrolltrigger gradient editorial counter gallery

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

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 .item blocks inside .items, each with an .item-img wrapper holding one <img> and an .item-info caption below it.
  • .counter > p starts as the text 0. .bg-color is 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 Ensemble
  • 0.312 47 Cybernetic Space Suit Design
  • 0.678 15 Martian Explorer Gear Concept
  • 0.289 51 Virtual Reality Avant-Garde Apparel
  • 0.563 34 Post-Apocalyptic Survival Outfit
  • 0.409 22 Interstellar Pilot Uniform Draft
  • 0.522 40 Underwater City Diver's Attire
  • 0.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 initial background: 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's z-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, from polygon(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 from y=0% down to y=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 no scrub. 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: onEnter and onEnterBack both call updateBackground(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 via onEnterBack).
  • overflow:hidden on .item-img and will-change:transform on the img are required for a clean clip reveal with the 1.25 zoom.
  • Counter caveat (keep as-is for fidelity): docHeight is computed once at DOMContentLoaded; 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 left offsets still apply (no dedicated media query in the source).