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 | 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: 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:rgba(255,255,255,0.2); with a ::after fill: 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 --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). They read as dark, moody, art-direction-heavy portfolio visuals:

  1. Abstract dark interactive-art visual with glowing warm light forms (slide 1 — initial).
  2. Avant-garde branding composition with bold sculptural shapes.
  3. Immersive light installation with luminous atmospheric glow.
  4. Futuristic virtual-reality digital environment.
  5. 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 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.