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).
h1font: 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 withobject-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:hiddenmake 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 aposition:relative<span>whosetopis 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 === " " ? " " : 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:0 → top:-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:200px → top:0 over 1.125s. On complete: remove the old active title, roll its letters from top:200px → top: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) + 1so image numbers cycle2→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 from250×350→100vw × 100vhover 2s (power3.out), becoming the new fullscreen active slide.- On its
onComplete: remove the old.slide-active, promote the grown.slide-nextto.slide-active(and strip the.slide-next-imgclass so the image fills), then unlockisAnimatingafter a 500mssetTimeout.
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
isAnimatingflag blocks clicks mid-transition; a click is only accepted again ~500ms after the box-grow completes. - No
prefers-reduced-motionbranch 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-pathmasks are load-bearing: the collapsed-center-point polygon hides the centered box until reveal, and the title window's full-rectangle clip +overflow:hiddenmask any letter/line translated outside the 175px band — keep both or the layout breaks.