# 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:
```js
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:
```js
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
```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:
```js
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:
```js
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):
```js
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)
```js
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.
