Scroll-Powered Full-Screen Project Carousel (Clip-Path Wipes + Marquee Titles)
Goal
Build a full-screen, editorial project carousel that is pinned by ScrollTrigger and driven entirely by scroll progress: as the user scrolls through 15 viewport-heights of pinned distance, 5 slides swap in and out. Each swap is a 1-second clip-path polygon wipe — the incoming slide unclips from one edge while the outgoing slide collapses toward the opposite edge — combined with opposing parallax y translations on the slide image (±25%) and the slide copy (±100%), all on power4.inOut. On every slide, the giant project title scrolls sideways forever as an infinite GSAP linear marquee, and 5 progress bars at the bottom fill left-to-right via a --progress CSS variable. Direction-aware: scrolling back reverses the wipe direction. Scroll is smoothed with Lenis.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) with the ScrollTrigger plugin, plus lenis for smooth scrolling:
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
gsap.registerPlugin(ScrollTrigger);
Everything runs inside a DOMContentLoaded listener. Wire Lenis the standard way:
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);
Keep the slide data in a separate slides.js ES module (default-exported array of { tag, marquee, image } objects) imported by the main script.
Layout / HTML
<body>
<nav>
<div class="logo"><a href="#">nova</a></div>
<div class="nav-items">
<a href="#">Home</a><a href="#">Projects</a><a href="#">Gallery</a>
<a href="#">Experiences</a><a href="#">Contact</a>
</div>
</nav>
<section class="intro">
<p>Where Vision Ignites and Boundaries Fade.</p>
</section>
<section class="carousel">
<div class="slide">
<div class="slide-img"><img src="(slide image 1)" alt="" /></div>
<div class="slide-copy">
<div class="slide-tag"><p>Website</p></div>
<div class="slide-marquee">
<div class="marquee-container">
<h1>Eclipse Interactive Art Portfolio</h1>
</div>
</div>
</div>
</div>
<div class="carousel-progress">
<div class="progress-bar"></div>
<div class="progress-bar"></div>
<div class="progress-bar"></div>
<div class="progress-bar"></div>
<div class="progress-bar"></div>
</div>
</section>
<section class="outro">
<p>Endless Horizons Await Beyond the Canvas.</p>
</section>
<script type="module" src="./script.js"></script>
</body>
Only the first slide exists in the HTML — the other 4 are created dynamically by JS. The class names .carousel, .slide, .slide-img, .slide-copy, .slide-tag, .slide-marquee, .marquee-container, .carousel-progress, .progress-bar are all queried by JS/CSS — keep them exact.
The 5 slides' data (in slides.js):
| # | tag | marquee title | |---|------------|--------------------------------------| | 1 | Website | Atlas · Interactive Art Archive | | 2 | Identity | Solano · Avant-Garde Brand System | | 3 | Exhibition | Lumen · Immersive Light Room | | 4 | Immersive | Halcyon · Virtual Reality Space | | 5 | Campaign | Ember · Global Launch Film |
Styling
Fonts: Space Grotesk (--display, the marquee title and the logo), Inter (--sans, body), Space Mono (--mono, tags and small labels).
Palette — a light page with one dark section: the still intro/outro sit on bone, the carousel itself inverts to near-black, and a single warm red is the only accent.
:root {
--bg: #f2f2f2;
--ink: #16161a;
--graphite: #2b2b31;
--accent: #ff4e45;
--muted: rgba(22, 22, 26, 0.58);
--bone-muted: rgba(242, 242, 242, 0.6);
--line: rgba(242, 242, 242, 0.24);
}
- Global reset
* { margin:0; padding:0; box-sizing:border-box; };body { background-color: var(--bg); color: var(--ink); font-family: var(--sans); }. h1(the marquee title):position:relative; white-space:nowrap; color: var(--bg); font-family: var(--display); font-size: clamp(4.5rem, 14vw, 12rem); font-weight:700; letter-spacing:-0.03em; line-height:1.04; will-change:transform;— huge, tightly tracked, and bone-coloured because it runs across the dark carousel.p:color: var(--ink); font-size:1.25rem; font-weight:400; letter-spacing:-0.01em; line-height:1.4;.a:color: var(--ink); text-decoration:none;;.logo a { font-family: var(--display); font-size:1.45rem; font-weight:700; letter-spacing:-0.02em; }— and inside the pinned carousel the nav flips tovar(--bg), with hover invar(--accent).img:position:relative; width:100%; height:100%; object-fit:cover; will-change:transform;.nav:position:fixed; top:0; left:0; width:100vw; padding:2em 4em; display:flex; justify-content:space-between; z-index:2;;.nav-items { display:flex; gap:1em; }.- Every
section:position:relative; width:100vw; height:100svh; display:flex; justify-content:center; align-items:center; background-color: var(--bg); overflow:hidden;— exceptsection.carousel, which isbackground-color: var(--ink). The.introand.outroalso carry a faintradial-gradient(ellipse 90% 55% at 50% 110%, rgba(255,78,69,.12), transparent 70%)lift from below. .slide, .slide-img:position:absolute; top:0; left:0; width:100%; height:100%; overflow:hidden;— slides stack on top of each other inside.carousel..slide: additionallydisplay:flex; align-items:flex-end; padding-bottom:5em;so the copy block sits near the bottom..slide-img img:position:relative; transform:scale(1.25); will-change:transform;— the 1.25 overscale is what gives the ±25% parallax travel headroom; do not omit it..slide-copy:position:relative; width:100%; overflow:hidden; will-change:transform; z-index:0;..slide-tag { padding:0 4em; };.slide-marquee { width:100%; overflow:hidden; };.marquee-container { width:1000%; }— the marquee track must be far wider than the viewport so the tripledh1never wraps..carousel-progress:position:absolute; bottom:0; width:100%; padding:4em; display:flex; justify-content:space-between; gap:1em; z-index:2;..progress-bar:position:relative; flex:1; width:100%; height:2px; background-color: var(--line);with a::afterfill:content:""; position:absolute; top:0; left:0; width:100%; height:100%; background-color: var(--accent); transform-origin:center left; transform:scaleX(var(--progress, 0)); will-change:transform;— the fill is driven purely by the--progressCSS variable set from JS.@media (max-width: 900px): hide.nav-items;nav { padding:2em; };.slide-tag { padding:0 2em; };.marquee-container { width:2000%; };.carousel-progress { padding:2em 1em; gap:0.5em; }.
GSAP effect (the important part — be exhaustive)
1. State + initial slide setup
Track four mutable variables: activeSlideIndex = 0, previousProgress = 0, isAnimatingSlide = false, triggerDestroyed = false.
On load, hard-set the initial slide fully visible:
gsap.set(initialSlide, { clipPath: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)" });
gsap.set(initialSlide.querySelector(".slide-img img"), { y: "0%" });
Then start the marquee on its h1 (below).
2. Infinite marquee — initMarqueeAnimation(h1)
Called for every slide's h1 (the initial one and each dynamically created one):
- Triple the text content:
h1.textContent = text + " " + text + " " + text; - Then one infinite linear tween:
gsap.to(h1, { x: "-33.33%", duration: 10, ease: "linear", repeat: -1, rotation: 0.01 });
Because the text is tripled and it travels exactly one third of its own width per cycle, the loop is seamless. The rotation: 0.01 is a deliberate sub-pixel-rendering hack — keep it.
3. Progress bars — updateProgressBars(progress)
Called on every ScrollTrigger update. For each of the 5 .progress-bar elements (index 0–4):
const barProgress = Math.min(Math.max(progress * 5 - index, 0), 1);
bar.style.setProperty("--progress", barProgress);
So bar *n* fills linearly while global progress runs from n/5 to (n+1)/5 — the bars fill one after another, and the CSS scaleX(var(--progress)) renders it. Fully reversible when scrolling back.
4. The pinned ScrollTrigger (the driver)
ScrollTrigger.create({
trigger: ".carousel",
start: "top top",
end: `+=${window.innerHeight * 15}px`, // 15 viewport-heights of pinned scroll
pin: true,
pinSpacing: true,
scrub: 1,
onUpdate: (self) => {
if (triggerDestroyed) return;
const progress = self.progress;
updateProgressBars(progress);
if (isAnimatingSlide) { previousProgress = progress; return; } // don't queue swaps mid-animation
const isScrollingForward = progress > previousProgress;
const targetSlideIndex = Math.min(Math.floor(progress * 5), 4);
if (targetSlideIndex !== activeSlideIndex) {
isAnimatingSlide = true;
try {
createAndAnimateSlide(targetSlideIndex, isScrollingForward);
activeSlideIndex = targetSlideIndex;
} catch (err) { isAnimatingSlide = false; }
}
previousProgress = progress;
},
onKill: () => { triggerDestroyed = true; },
});
Key semantics: the pinned distance is split into 5 equal bands (floor(progress * 5), clamped to 4). Crossing a band boundary fires one discrete slide transition — the transition itself is time-based (1s tweens), not scrubbed; only *when* it fires is scroll-driven. isAnimatingSlide gates re-entry so a fast scroll can't stack transitions; progress keeps being recorded so direction detection stays correct.
5. Slide transition — createAndAnimateSlide(index, isScrollingForward)
Build a brand-new .slide DOM node from slides[index] (image, tag p, marquee h1 with the slide's title), call initMarqueeAnimation on its h1, then gsap.killTweensOf the current slide, its .slide-img, and its .slide-copy before animating. All tweens below use duration: 1, ease: "power4.inOut".
Forward (scrolling down) — new slide wipes up from the bottom:
- Initial
gsap.seton the new slide:clipPath: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)"(a zero-height line at the bottom edge); itsimgaty: "25%"; its.slide-copyaty: "100%". carousel.appendChild(newSlide)(stacks on top of the current one).- Animate the new slide's clip to full:
clipPath: "polygon(0% 100%, 100% 100%, 100% 0%, 0% 0%)"— the top edge of the clip travels from the bottom to the top of the screen. - Simultaneously tween
[newSlideCopy, newSlideImg]toy: "0%"— image and copy slide up into place as the wipe reveals them (a counter-parallax entrance). - Simultaneously collapse the current slide:
clipPath: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)"(a zero-height line at the top edge). In itsonStart, push the outgoing image toy: "-25%"and outgoing copy toy: "-100%"(both 1s power4.inOut) — the old slide's content drifts upward as it is swallowed. InonComplete,currentSlide.remove()and releaseisAnimatingSlide = false; also release the flag inonInterrupt.
Backward (scrolling up) — exact mirror, wiping down from the top:
- New slide starts as a zero-height line at the top:
clipPath: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)"; itsimgaty: "-25%"; its.slide-copyaty: "-100%". - Insert it before the current slide (
carousel.insertBefore(newSlide, currentSlide)). - Animate its clip to full:
clipPath: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)"; tween[newSlideImg, newSlideCopy]toy: "0%". - Collapse the current slide toward the bottom:
clipPath: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)", withonStartpushing the outgoing image toy: "25%"and copy toy: "100%"; sameonComplete/onInterruptcleanup.
Net result: only one (or briefly two, mid-transition) .slide nodes ever exist in the DOM; the carousel always converges back to a single slide.
Assets / images
5 full-bleed project images, landscape (viewport-filling, ~16:9), one per slide, displayed with object-fit: cover at scale(1.25). The set is one idea photographed five ways — smoke on black, graded to greyscale — so the wipes read as one continuous piece and nothing competes with the bone marquee type:
- A monochrome smoke curl (slide 1 — initial).
- An elegant spiralling column.
- A vertical rising column.
- A flowing corkscrew.
- A dramatic horizontal burst.
Use the neutral brand name "sable" in the nav logo — no real company names.
Behavior notes
- Page flow: intro section (1 viewport) → carousel pinned for
15 × innerHeight→ outro section. Nav stays fixed on top (z-index: 2) throughout. - Transitions are direction-aware and fire once per band crossing; skipping multiple bands in one fast scroll jumps straight to the target index (intermediate slides are skipped, not queued).
- Progress bars are continuously scrubbed (fully reversible); slide swaps are discrete 1s animations.
- Marquee runs on every slide, always, independent of scroll.
scrub: 1+ Lenis inertia give the damped feel — don't usescrub: true.- Mobile (≤900px): nav links hidden, tighter paddings, marquee track widened to 2000%; the effect itself runs unchanged. Sections use
100svh. - No SplitText, no CustomEase, no Three.js, no reduced-motion branch in the original.
Images
This component ships with 5 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/eseagency-scroll-carousel-javascript/slide-img-1.jpg
https://motionprompts.dev/c/eseagency-scroll-carousel-javascript/slide-img-2.jpg
https://motionprompts.dev/c/eseagency-scroll-carousel-javascript/slide-img-3.jpg
https://motionprompts.dev/c/eseagency-scroll-carousel-javascript/slide-img-4.jpg
https://motionprompts.dev/c/eseagency-scroll-carousel-javascript/slide-img-5.jpg
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—--bg,--ink,--graphite,--accent,--muted,--bone-muted,--line, plus the type variables--display,--sans,--mono. 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. - Smooth scroll (Lenis) — this creates its own Lenis instance, and a page may only have one. If your project already runs Lenis, drop the setup shown above and reuse the existing instance, keeping the
lenis.on("scroll", ScrollTrigger.update)wiring once. Two instances fight over the same scroll and stutter visibly, with no error in the console.
Adapting this to React
Everything above describes a standalone document: one script that runs once behind DOMContentLoaded, drives Lenis off GSAP's own ticker, and hands scroll progress to a single ScrollTrigger whose onUpdate builds and tears down .slide elements by hand. React withdraws all three guarantees at once, and here the DOM mutation is the trap: this component doesn't just create tweens on existing nodes, it creates and removes entire .slide elements with document.createElement / appendChild / insertBefore / remove() — none of that is GSAP output, so gsap.context has no visibility into it at all.
Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again, but the DOM itself is not torn down between those two runs — the .carousel subtree stays exactly as the first effect left it. If the first run's cleanup fires while a slide swap is mid-flight — the one-second clip-path wipe hasn't reached its onComplete yet — two .slide elements are still sitting inside .carousel when the second effect starts. Its first line, the scoped equivalent of document.querySelector(".carousel .slide"), now returns whichever of the two happens to be first in document order; activeSlideIndex resets to 0 regardless of which slide is actually on screen, and the progress bars start fresh against a carousel that's still visually frozen mid-wipe. None of this reproduces in a production build, because only development double-invokes effects. Treat the cleanup as part of the effect, not as an afterthought.
*(1) The entry point* — The script waits for DOMContentLoaded with no readyState guard. By the time a React component mounts, that event has already fired, so the listener is never called and none of this runs — no initial clip-path, no marquee, no pin, no error either. Delete the listener and move its body — the Lenis/ticker wiring, the activeSlideIndex / previousProgress / isAnimatingSlide / triggerDestroyed bindings, the initial slide's gsap.set calls, initMarqueeAnimation on the first h1, and the single ScrollTrigger.create — into a useEffect with an empty dependency array. Plugin registration (gsap.registerPlugin(ScrollTrigger)) stays at module scope, run once at import time, not inside the effect. All four state variables should stay plain let bindings local to the effect, not useState: none of them ever needs to cause a render, and resetting activeSlideIndex to 0 and previousProgress to 0 on every effect run is exactly what a fresh mount should do — as long as the DOM they describe actually matches that reset, which the cleanup below has to guarantee.
*(2) Element lookups* — Three places reach into document on the assumption this markup is the only copy on the page: the initial document.querySelector(".carousel .slide"), the .carousel lookup at the top of createAndAnimateSlide, and document.querySelectorAll(".progress-bar") inside updateProgressBars. Give the component's outermost element a root ref, and give .carousel a second, dedicated ref — it's both the ScrollTrigger trigger and the node new slides get appended into or inserted before, so resolving it once up front is cleaner than re-querying it from the root ref on every call. Resolve the initial slide and the progress bars from that same root. createAndAnimateSlide also reaches back into its own freshly built newSlide node with .querySelector(".slide-img img") / .querySelector(".slide-copy") — those two are already scoped to a detached node it just created and need no change.
*(3) Cleanup* — three mechanisms need three different teardowns here, and the order between them matters.
*GSAP / ScrollTrigger.* Wrap the setup in a gsap.context scoped to the root ref. The ScrollTrigger.create call itself is captured automatically, but its onUpdate runs later, on every scroll tick — well outside the synchronous window the context tracks — and onUpdate is exactly where createAndAnimateSlide gets invoked, which is exactly where every gsap.to this component ever creates lives. Register createAndAnimateSlide on the context with the named-handler form of add, and rename the context's own factory parameter away from self first: ScrollTrigger.create's callback already uses that name for the trigger instance in this script (onUpdate: (self) => { const progress = self.progress; … }), and if the factory parameter keeps the same name, the inner one silently shadows the outer one — any self.add(...) written inside onUpdate would resolve to the trigger, which has no add method, not to the context.
const ctx = gsap.context((context) => {
// the two initial gsap.set calls, initMarqueeAnimation on the first h1
context.add("swapSlide", (index, isScrollingForward) => {
// the body of createAndAnimateSlide, unchanged
});
ScrollTrigger.create({
trigger: carouselRef.current,
start: "top top",
end: `+=${window.innerHeight * 15}px`,
pin: true,
pinSpacing: true,
scrub: 1,
onUpdate: (trigger) => {
// updateProgressBars, the direction/band math, then
// context.swapSlide(targetSlideIndex, isScrollingForward)
// in place of calling createAndAnimateSlide directly
},
});
}, rootRef);
return () => ctx.revert();
With swapSlide registered this way, ctx.revert() now also kills whichever wipe tween is still running at unmount time, along with the trigger and the two initial gsap.sets. What it does not do is undo the DOM node swapSlide inserted with appendChild or insertBefore — a context reverts GSAP output, not arbitrary element creation, and building .slide elements by hand is exactly what this component does on every scroll-driven swap. If revert lands mid-swap, the surplus .slide is still sitting in .carousel afterward, which is the scenario described above. Track the node the current effect run has inserted — a let extraSlide declared next to ctx, assigned at both carousel.appendChild(newSlide) and carousel.insertBefore(newSlide, currentSlide), and cleared in every onComplete once the old slide is removed — and in the cleanup, if it's still attached, remove it explicitly before reverting the context. That's what keeps the next effect run's slide lookup down to exactly one node, matching the activeSlideIndex = 0 it's about to reset to.
*Lenis and the ticker.* gsap.ticker.add((time) => lenis.raf(time * 1000)) is this component's entire render loop, and a gsap.context has no visibility into ticker subscriptions — they are neither a tween nor a trigger. Keep the function reference and remove that same reference with gsap.ticker.remove in the cleanup, before calling lenis.destroy(), not after, so a tick already scheduled by GSAP's engine doesn't call .raf() on an instance that's already gone. Subscribe lenis.on("scroll", ScrollTrigger.update) through a named reference the same way, so it can be removed with lenis.off by identity rather than left dangling on an instance about to be discarded. gsap.ticker.lagSmoothing(0) is global ticker state; calling it again on the next mount is harmless.
The triggerDestroyed flag and its onKill callback become redundant once ctx.revert() is doing the killing: ScrollTrigger.kill() still fires onKill synchronously, so the flag flips correctly, but nothing reads it afterward, because the trigger is already gone and can't call onUpdate again. Leaving it as written costs nothing; it just stops doing real work under this ordering.