All components

ESEAgency Scroll-Powered Carousel

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

Open live demo ↗ Raw prompt (.md)

What it does

A full-screen project carousel pinned by ScrollTrigger (scrub, Lenis-smoothed) where scroll progress drives slides in and out: each transition animates a clip-path polygon wipe plus opposing y-translations on the image and copy with power4.inOut, while a looping GSAP linear marquee scrolls the title and five progress bars fill via a --progress CSS variable.

How it's built

Categoryslider
Techgsap, lenis
GSAP pluginsScrollTrigger
Complexitypage
Performance costlight
Mobile-safeyes
Scrollhijacks scrolling

scroll carousel slider pin scrub clip-path marquee lenis editorial

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

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 to var(--bg), with hover in var(--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; — except section.carousel, which is background-color: var(--ink). The .intro and .outro also carry a faint radial-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: additionally display: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 tripled h1 never 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 ::after fill: 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 --progress CSS 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.set on the new slide: clipPath: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)" (a zero-height line at the bottom edge); its img at y: "25%"; its .slide-copy at y: "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] to y: "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 its onStart, push the outgoing image to y: "-25%" and outgoing copy to y: "-100%" (both 1s power4.inOut) — the old slide's content drifts upward as it is swallowed. In onComplete, currentSlide.remove() and release isAnimatingSlide = false; also release the flag in onInterrupt.

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%)"; its img at y: "-25%"; its .slide-copy at y: "-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] to y: "0%".
  • Collapse the current slide toward the bottom: clipPath: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)", with onStart pushing the outgoing image to y: "25%" and copy to y: "100%"; same onComplete/onInterrupt cleanup.

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:

  1. A monochrome smoke curl (slide 1 — initial).
  2. An elegant spiralling column.
  3. A vertical rising column.
  4. A flowing corkscrew.
  5. 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 use scrub: 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: --ink is defined by 164 of the 219 components in this catalogue, --paper by 94, --muted by 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.