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.
Images
This component ships with 10 reference assets, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.
https://motionprompts.dev/c/gsap-clip-carousel/1.jpg
https://motionprompts.dev/c/gsap-clip-carousel/10.jpg
https://motionprompts.dev/c/gsap-clip-carousel/2.jpg
https://motionprompts.dev/c/gsap-clip-carousel/3.jpg
https://motionprompts.dev/c/gsap-clip-carousel/4.jpg
https://motionprompts.dev/c/gsap-clip-carousel/5.jpg
… 4 more under https://motionprompts.dev/c/gsap-clip-carousel/
They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.
Using this outside its demo page
This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.
- Palette on
:root—--room,--shade,--cane,--brass,--space-1,--space-2,--space-3. These names are not namespaced and they collide:--inkis defined by 164 of the 219 components in this catalogue,--paperby 94,--mutedby 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix. - **Rules on
*,body** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it. - Full-screen overlay — a fixed element covers the viewport (a loader or transition). Only one may exist per page and it must remove itself when done. If your page already has one, keep that and drop this; otherwise the second silently hides the first.
Adapting this to React
Everything above describes a standalone document: one script that runs once, reaches into the page with document.querySelector, and never has to undo itself. React withdraws all three of those guarantees at once, and it does it quietly — the component renders, looks right for a moment, and then misbehaves in a way that does not point back at any of this.
Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. Setup that runs twice with teardown that runs never leaves you two of everything: two click listeners advancing the carousel on the same event, two zoom tweens racing on the same outgoing image. The visible symptom is a carousel that jumps two images per click, or a title that rolls twice as fast, and it will not reproduce in a production build, because React only does the double mount in development. Treat the cleanup as part of the effect, not as an afterthought.
*(1) The entry point* — The script above runs at the top level: the initial clip-path reveal (the gsap.to(".slide-next-img", …) call that opens the second box after the load delay) and the document.addEventListener("click", …) registration both fire the moment the module is evaluated, which in React is import time, before this component has rendered its nav, .slider or .slider-content markup. Move both into a useEffect with an empty dependency array. Do not leave either in the component body: a document.addEventListener call sitting in the render path re-subscribes on every render, stacking one more listener behind the last.
*(2) Element lookups* — Every document.querySelector in the code above assumes this component owns the document. Give the component a root ref, render it on the outermost element (the one wrapping nav, .copy, footer and .slider), and scope every lookup — .slide-active img, .slide-next-img, .slider-content-active, .slider-content-next — to it. This matters more than usual here: document.querySelector(".slide-next-img") is deliberately unscoped by class in the vanilla version and relies on document order to return the box that is *already visible*, not the one just appended — the prompt calls this out above as a load-bearing ordering subtlety. During the StrictMode double-mount, two copies of .slider exist for an instant, and an unscoped query can return the outgoing copy's box instead of the live one — the grow-to-fullscreen tween then animates an element that is about to be torn down while the real box stays at its small centered size forever. The document-level click listener itself is the one lookup that should not be narrowed to the root ref: advancing on a click anywhere on the page is the feature, not an oversight. Everything the handler looks up once it fires still needs to be scoped.
*(3) Cleanup* — Wrap every tween this component creates in a gsap.context scoped to the root ref, and revert that context in the cleanup:
useEffect(() => {
const ctx = gsap.context((self) => {
// initial reveal + document click listener, as described above
}, rootRef);
return () => ctx.revert();
}, []);
Without the revert, a StrictMode remount leaves a second click listener wired to the same document and a stray tween still holding an inline clip-path on a box that no longer belongs to the live tree.
This component's animation graph, though, does not live where gsap.context can see it by default. Every tween here fires from inside the click handler or from an onComplete chained off another tween — the zoom, both title rolls, the new box's reveal and the grow-to-fullscreen tween are all created after the factory above has already returned once, on a later, separate turn of the event loop. gsap.context only auto-tracks what gets created while its factory is synchronously running; a gsap.to fired from a click seconds later is invisible to it unless you say otherwise. So wrap the handler's body, and every onComplete that itself spawns a tween or touches the DOM, in self.add(() => { … }) — the one-argument overload, which runs immediately and attributes whatever GSAP creates inside it to the context:
const onClick = () => {
if (isAnimating.current) return;
isAnimating.current = true;
self.add(() => {
// splitTextIntoSpans, the zoom tween, both title-roll tweens, the new-box
// insertAdjacentHTML + reveal tween, and the grow-to-fullscreen tween —
// each onComplete below needs its own self.add(...) wrapper for the same reason
});
};
document.addEventListener("click", onClick);
Skip this and ctx.revert() mid-transition kills nothing at all — not because it fails, but because it never had anything to kill: the tweens it should have owned were created outside its watch, so they keep animating a .slide-next box that insertAdjacentHTML appended into a subtree React has already unmounted.
That same imperative DOM surgery is the other thing to carry over deliberately rather than by habit. Nothing under .slider or .slider-content is ever re-rendered from React state — the handler appends new .slide-next / .slider-content-next nodes with insertAdjacentHTML, reclassifies them with classList, and removes the outgoing ones by hand, exactly as the vanilla version does. Keep it that way: render the two starting slides and the two starting title lines once from JSX, then treat that subtree as a React-opaque island the effect owns outright, the same way the code above already treats it. Do not lift currentImageIndex, currentContentIndex, or isAnimating into useState — none of them drive JSX, and setting them as state would schedule a re-render of a tree that a chained onComplete is, at that exact moment, mutating by hand with raw DOM calls. Keep them as useRefs, mutated exactly where the closures above mutate the plain variables.