All components

GSAP Clip Carousel

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

Open live demo ↗ Raw prompt (.md)

What it does

A click-to-advance fullscreen product carousel: each click reveals the next image from a center clip-path box that scales to fill the viewport while the active image zooms (scale 2), and the product name transitions via per-character span staggers sliding up and out as the next name rises in. Driven entirely by gsap.to with power3.out eases and animated clipPath polygons.

How it's built

Categoryslider
Techgsap
Complexitypage
Performance costlight
Mobile-safeyes

gsap carousel clip-path click image-reveal product-showcase stagger editorial fullscreen

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

Fullscreen Click-Driven Product Carousel — Center Clip-Path Reveal + Box-to-Viewport Grow + Zoom + Per-Character Title Roll

Goal

Build a fullscreen (100vw × 100vh) editorial product carousel that advances on every click anywhere on the page. Each click reveals the next product image inside a small centered box whose clip-path opens from a single center point, and that box then grows to fill the whole viewport to become the new active slide — while the outgoing active image simultaneously zooms to scale 2. In sync, the current product name rolls up and out one character at a time (per-letter stagger) as the next name rises into place from below. Everything is driven by plain gsap.to/gsap.set with power3.out eases and animated clipPath polygons — no ScrollTrigger, no plugins.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use only gsap (npm)import gsap from "gsap". No GSAP plugins (no ScrollTrigger, no SplitText — the character split is done by hand), no Lenis, no Three.js. All code runs at module top level (the initial reveal fires immediately on load; the rest is inside a single document click listener).

Layout / HTML

nav                              ← fixed top bar, 3 flex columns (each flex:1)
  .logo   > a         "Echo Node"           (fictional brand name)
  .links  > a×4       "Home," "Products," "Info," "Contact,"
  .shop   > a×3       "Search" "Account" "Cart"

.copy                            ← absolute, vertically centered band
  p "Lifestyle Catalogue"
  p "2024 / 2025"

footer                           ← absolute bottom-right
  p "Launching Soon"

.slider                          ← fullscreen viewport, overflow hidden
  .slide-active > img            ← image #1 (the starting full-bleed slide)
  .slide-next                    ← flex-centered wrapper
    .slide-next-img > img        ← image #2, inside the small centered clip box

.slider-content                  ← bottom-left masked title window (height 175px)
  .slider-content-active > h1    ← first product name  (e.g. "LuminaPad")
  .slider-content-next   > h1    ← second product name (e.g. "PulseEar")

Only these two slides and two title lines exist in the HTML. Every subsequent slide box and title line is created in JS with insertAdjacentHTML and appended, then reclassified after each transition.

Styling

  • Global reset: * { margin:0; padding:0; box-sizing:border-box }.
  • Body font: a neutral grotesque sans (original uses "PP Neue Montreal"; fall back to Helvetica Now / Inter / system sans). h1 font: a tall, condensed, light-weight display serif (original uses "Timmons NY 2.005"; fall back to a condensed serif such as a light "Times"/"PT Serif Caption"-style face if unavailable).
  • img { width:100%; height:100%; object-fit:cover }.
  • p, a { text-decoration:none; color:#fff; font-size:14px } — all UI text is white over the imagery.
  • nav: position:fixed; top:0; width:100%; padding:2em; display:flex; align-items:center; z-index:2. nav > div { flex:1 }. .links { display:flex; justify-content:center; align-items:center; gap:0.5em }. .shop { display:flex; justify-content:flex-end; align-items:center; gap:2em }.
  • .copy: position:absolute; top:45%; transform:translateY(-50%); width:100vw; padding:2em; display:flex; justify-content:space-between; align-items:center; z-index:2 (label on the left, date on the right).
  • footer: position:absolute; bottom:0; width:100vw; padding:2em; display:flex; justify-content:flex-end; align-items:center; z-index:2.
  • .slider: position:absolute; top:0; left:0; width:100vw; height:100vh; overflow:hidden.
  • .slide-active: position:absolute; width:100%; height:100% (its <img> fills the whole viewport).
  • .slide-next: position:absolute; width:100%; height:100%; display:flex; justify-content:center; align-items:center (centers its child box).
  • The centered clip box.slide-next .slide-next-img: width:250px; height:350px; clip-path:polygon(50% 50%, 50% 50%, 50% 50%, 50% 50%). All four polygon points collapse to the exact center, so the box is invisible until animated open. Its inner <img> still fills 250×350 with object-fit:cover.
  • The title window.slider-content: position:absolute; left:2em; bottom:0; width:100%; height:175px; clip-path:polygon(0 0, 100% 0, 100% 100%, 0% 100%); overflow:hidden. The full-rectangle clip + overflow:hidden make it a 175px-tall mask that hides any title translated outside it.
  • .slider-content-active: position:absolute; top:0; left:0; clip-path:polygon(0 0, 100% 0, 100% 100%, 0% 100%).
  • .slider-content-next: position:absolute; top:200px; left:0; clip-path:polygon(0 0, 100% 0, 100% 100%, 0% 100%) — starts 200px below the window (out of sight) so it can rise in later.
  • h1: text-transform:uppercase; font-size:150px; font-weight:lighter; color:#fff. h1 span { position:relative } — the per-character split wraps each letter in a position:relative <span> whose top is what the tweens animate.
  • Responsive @media (max-width:900px): hide .links, .shop, footer; center .logo (justify-content:center; text-align:center).

Product names (fictional, in order)

A JS array of 10 neutral tech-product names used for the rolling titles, e.g.:

const sliderContent = ["LuminaPad","PulseEar","ZenithWatch","AeroCharge","NimbusCam",
                       "EclipseDrive","TerraHub","QuantumKey","MeshRouter","AuraBeam"];

No real brands. totalImages = 10.

GSAP effect (be exhaustive — this is the point of the component)

State
let currentImageIndex = 2;    // image # currently in the centered "next" box (starts at 2.jpg)
let currentContentIndex = 1;  // index into sliderContent of the "next" title (starts at "PulseEar")
const totalImages = 10;
let isAnimating = false;      // lock; ignores clicks while a transition is in flight
Manual character splitter
function splitTextIntoSpans(selector) {
  document.querySelectorAll(selector).forEach((el) => {
    el.innerHTML = el.innerText
      .split("")
      .map((ch) => `<span>${ch === " " ? "&nbsp;&nbsp;" : ch}</span>`)
      .join("");
  });
}

Each letter becomes a position:relative <span>; the tweens then animate each span's top.

1) Initial reveal on load (fires immediately, no click)
gsap.to(".slide-next-img", {
  clipPath: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
  duration: 1.5, ease: "power3.out", delay: 1
});

After a 1s delay, the collapsed center-point clip-path of the first centered box expands to a full rectangle over 1.5s — image #2 fades into view inside the 250×350 box while image #1 fills the background.

2) Click handler (the only interaction) — everything below runs on each document click

Guard first: if (isAnimating) return; isAnimating = true;

All of the following tweens are fired concurrently (they are separate gsap.to calls, not one timeline), so the outgoing zoom, the box-grow, the clip reveal of the new box, and the two title rolls all overlap.

(a) Zoom the outgoing active image

splitTextIntoSpans(".slider-content-active h1");   // split the CURRENT title into spans first
gsap.to(".slide-active img", { scale: 2, duration: 2, ease: "power3.out" });

The full-bleed active image scales from 1 → 2 over 2s (power3.out) — a slow zoom that plays under the incoming slide.

(b) Roll the current title UP and out

gsap.to(".slider-content-active h1 span", {
  top: "-175px", stagger: 0.05, ease: "power3.out", duration: 0.5,
  onComplete: () => {
    gsap.to(".slider-content-active", { top: "-175px", duration: 0.25, ease: "power3.out" });
  }
});

Each letter of the active name lifts from top:0top:-175px (up through the top of the 175px mask) with a 0.05s per-character stagger, 0.5s each. When the letters finish, the whole .slider-content-active container itself slides to top:-175px over 0.25s (final tuck-away).

(c) Roll the next title IN from below

splitTextIntoSpans(".slider-content-next h1");
gsap.set(".slider-content-next h1 span", { top: "200px" });   // letters pre-offset 200px down

gsap.to(".slider-content-next", {
  top: "0", duration: 1.125, ease: "power3.out",
  onComplete: function () {
    document.querySelector(".slider-content-active").remove();          // drop old active title
    gsap.to(".slider-content-next h1 span", {
      top: 0, stagger: 0.05, ease: "power3.out", duration: 0.5          // letters settle up into place
    });
    // promote next → active
    const nextContent = document.querySelector(".slider-content-next");
    nextContent.classList.remove("slider-content-next");
    nextContent.classList.add("slider-content-active");
    nextContent.style.top = "0";
    // queue the following title as the new "next", parked 200px below
    currentContentIndex = (currentContentIndex + 1) % totalImages;
    const nextText = sliderContent[currentContentIndex];
    document.querySelector(".slider-content")
      .insertAdjacentHTML("beforeend",
        `<div class="slider-content-next" style="top: 200px;"><h1>${nextText}</h1></div>`);
  }
});

The next-title container rises from top:200pxtop:0 over 1.125s. On complete: remove the old active title, roll its letters from top:200pxtop:0 (stagger 0.05, 0.5s each), reclassify it as the new active, and append the following product name as a fresh .slider-content-next parked 200px below.

(d) Reveal the NEW centered box, then grow the CURRENT box to fullscreen

currentImageIndex = (currentImageIndex % totalImages) + 1;   // advance image #, wraps 10→1

// append a brand-new centered box for the NEXT-next image
document.querySelector(".slider").insertAdjacentHTML("beforeend", `
  <div class="slide-next">
    <div class="slide-next-img"><img src="/c/gsap-clip-carousel/${currentImageIndex}.jpg" alt="" /></div>
  </div>`);

// open the clip-path of that NEW (last-child) box from center point → full rectangle
gsap.to(".slider .slide-next:last-child .slide-next-img", {
  clipPath: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
  duration: 1.5, ease: "power3.out", delay: 0.5
});

// grow the ALREADY-VISIBLE box (querySelector returns the FIRST .slide-next-img in DOM) to fill the screen
const slideNextImg = document.querySelector(".slide-next-img");
gsap.to(slideNextImg, {
  width: "100vw", height: "100vh", duration: 2, ease: "power3.out",
  onComplete: function () {
    document.querySelector(".slide-active")?.remove();                 // drop old active slide
    const nextSlide = document.querySelector(".slide-next");
    nextSlide.classList.remove("slide-next");
    nextSlide.classList.add("slide-active");                            // promote box wrapper → active
    nextSlide.querySelector(".slide-next-img").classList.remove("slide-next-img"); // its box now fills
    setTimeout(() => { isAnimating = false; }, 500);                    // unlock 500ms after grow ends
  }
});

Key ordering subtlety to reproduce exactly:

  • currentImageIndex = (currentImageIndex % totalImages) + 1 so image numbers cycle 2→3→…→10→1→2….
  • The newly appended box (.slide-next:last-child) is the one whose clip-path opens (delay 0.5, 1.5s).
  • document.querySelector(".slide-next-img") returns the first such box in the DOM — i.e. the box that was *already visible* from the previous cycle. That box is the one that grows from 250×350100vw × 100vh over 2s (power3.out), becoming the new fullscreen active slide.
  • On its onComplete: remove the old .slide-active, promote the grown .slide-next to .slide-active (and strip the .slide-next-img class so the image fills), then unlock isAnimating after a 500ms setTimeout.
Net choreography of one click

Old image zooms to 2× underneath → the centered box that was showing the next image scales open to fill the viewport as the new active slide → a fresh centered box for the following image clip-reveals in the middle → the old title rolls up and out letter-by-letter while the next title rolls up into the masked window. The isAnimating lock (released ~2.5s after the click) prevents overlapping transitions from rapid clicks.

Assets / images

10 editorial product/lifestyle photographs, filenames 1.jpg … 10.jpg. Each is used both as a full-bleed background (object-fit:cover, 100vw × 100vh) and as a small centered card in the portrait 250×350 (≈5:7) clip box before it grows — so keep the subject centered and give each a distinct dominant color/subject so the advance reads clearly. Moody, elegant, catalogue/editorial mood (products, objects, lifestyle scenes). No logos or brand marks. Image #1 starts as the active slide, image #2 is pre-loaded in the first centered box.

Behavior notes

  • Click-anywhere to advance, no scroll, no autoplay, no wrap-limit — it cycles forward through all 10 images/titles indefinitely (% totalImages).
  • The isAnimating flag blocks clicks mid-transition; a click is only accepted again ~500ms after the box-grow completes.
  • No prefers-reduced-motion branch in the original. Desktop-first: below 900px the nav links, shop links and footer hide and the logo centers, but the click carousel itself still works.
  • The two clip-path masks are load-bearing: the collapsed-center-point polygon hides the centered box until reveal, and the title window's full-rectangle clip + overflow:hidden mask any letter/line translated outside the 175px band — keep both or the layout breaks.