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 | Eclipse Interactive Art Portfolio | | 2 | Branding | Solaris Avant-Garde Brand Identity | | 3 | Experience | Nova Immersive Light Exhibition | | 4 | Website | Luminex Virtual Reality Showcase | | 5 | Marketing | Orion Digital Art Launch Campaign |
Styling
Google Font: "Inter" (variable, weights 100–900). Dark editorial look — every section has background-color: #0f0f0f; all text is #fff.
- Global reset
* { margin:0; padding:0; box-sizing:border-box; };body { font-family:"Inter", sans-serif; }. h1(the marquee title):position:relative; color:#fff; font-size:10rem; font-weight:700; letter-spacing:-0.4rem; line-height:1.5; will-change:transform;— huge, tightly tracked.p:color:#fff; font-size:1.25rem; font-weight:500; letter-spacing:-0.04rem; line-height:1;.a:color:#fff; text-decoration:none; font-size:0.8rem; font-weight:500;;.logo a { font-size:2rem; font-weight:600; letter-spacing:-0.06rem; }.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:#0f0f0f; overflow:hidden;. .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:rgba(255,255,255,0.2);with a::afterfill:content:""; position:absolute; top:0; left:0; width:100%; height:100%; background-color:#fff; 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). They read as dark, moody, art-direction-heavy portfolio visuals:
- Abstract dark interactive-art visual with glowing warm light forms (slide 1 — initial).
- Avant-garde branding composition with bold sculptural shapes.
- Immersive light installation with luminous atmospheric glow.
- Futuristic virtual-reality digital environment.
- Vivid, high-energy digital-art campaign graphic.
Use the neutral brand name "nova" 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.