# Pinned Horizontal Scroll Gallery with Image Parallax & Masked Title Reveals

## Goal

Build a full-page scroll experience where a pinned section converts vertical scrolling into a horizontal 5-slide image strip: as the user scrolls down, the strip slides left, each full-bleed image gets an opposing parallax drift, and each slide's big uppercase title slides up into view (through a clipping mask) exactly when its slide becomes visible. After the strip finishes, the page releases into a dark outro section.

## Tech

Vanilla HTML/CSS/JS with ES module imports. Use `gsap` (npm) plus the GSAP plugin `ScrollTrigger`, and `lenis` (npm) for smooth scrolling. No other libraries.

## Layout / HTML

```
<body>
  <section class="sticky">
    <div class="slider">
      <div class="slides">
        <div class="slide">
          <div class="img"><img src="..." alt="" /></div>
          <div class="title"><h1>Refined Reception<br />Lasting Impact</h1></div>
        </div>
        ... (5 slides total, same structure)
      </div>
    </div>
  </section>
  <section class="outro">
    <h1>Shaping timeless spaces with contemporary vision</h1>
  </section>
  <script type="module" src="./script.js"></script>
</body>
```

The 5 slide titles (each is one `<h1>` with a `<br />` splitting it into two lines):

1. `Refined Reception<br />Lasting Impact`
2. `Practical Luxury<br />Smart Living`
3. `Modern Concrete<br />Warm Details`
4. `Curved Elements<br />Modern Flow`
5. `Minimal Design<br />Natural Light`

## Styling

- Global reset: `* { margin: 0; padding: 0; box-sizing: border-box; }`.
- `html, body`: `width: 100%; height: 700vh;` (the tall body provides the scroll runway), `font-family: "Gilroy"` (a heavy geometric sans; fall back to `sans-serif` — no webfont import needed).
- Every `section`: `position: relative; width: 100vw; height: 100vh; padding: 1.5em; overflow: hidden;`.
- `section.sticky`: `background-color: #b4aea7` (warm greige, visible as a frame around the slider because of the section padding).
- `section.outro`: `background-color: #141414`, flex, `justify-content: center; align-items: center; text-align: center`. Its `h1`: `color: #fff; text-transform: uppercase; font-size: 60px; font-weight: 900; letter-spacing: -2px; line-height: 0.9;`.
- `.slider`: `position: relative; width: 100%; height: 100%; overflow: hidden;` (this is the viewport/mask for the strip).
- `.slides`: `position: relative; width: 500%; height: 100%; display: flex; will-change: transform; transform: translateX(0);` — the horizontally-moving track, 5× the slider width.
- `.slide`: `position: relative; flex: 1; height: 100%;` (each slide is exactly one slider-width wide).
- `.img`: `position: absolute; width: 100%; height: 100%; overflow: hidden;` (crops the parallaxing image).
- `img`: `position: relative; width: 100%; height: 100%; object-fit: cover; will-change: transform, scale; transform: translateX(0) scale(1.35);` — the 1.35 upscale gives the image extra bleed so it can shift horizontally without showing edges.
- `.title`: `position: relative; width: max-content; height: 200px; margin: 1.5em; z-index: 2;` and crucially `clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%);` — a full-rectangle clip that masks the `h1` when it is translated above the box (this is what makes the title "rise from nothing").
- `.title h1`: `color: #fff; text-transform: uppercase; font-size: 85px; font-weight: 900; letter-spacing: -2px; line-height: 0.9; will-change: transform;`.
- Include the standard Lenis helper CSS (`.lenis.lenis-smooth [data-lenis-prevent] { overscroll-behavior: contain; }`, `.lenis.lenis-stopped { overflow: clip; }`, `.lenis.lenis-smooth iframe { pointer-events: none; }`).

## GSAP effect (be exact)

Wrap everything in `DOMContentLoaded`. Register `ScrollTrigger`.

### 1. Lenis smooth scroll wired into GSAP's ticker

```
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
```

### 2. Measurements (computed once on load)

- `stickyHeight = window.innerHeight * 6` — the pin distance (6× viewport height).
- `slideWidth = slider.offsetWidth` — width of one slide (= visible slider width).
- `totalMove = slidesContainer.offsetWidth - slider.offsetWidth` — total horizontal travel of the track (i.e. 4 × slideWidth, since the track is 500% wide).

### 3. Initial title state

For every slide, `gsap.set(titleH1, { y: -200 })`. Combined with the `.title` container's 200px height and rectangular `clip-path`, every headline starts fully hidden above its mask.

### 4. Title reveal via IntersectionObserver (NOT ScrollTrigger)

Create one `IntersectionObserver` with `{ root: slider, threshold: [0, 0.25] }`, observing all 5 `.slide` elements. Track a `currentVisibleIndex` variable (initially `null`). In the callback, for each entry compute `currentIndex` (the slide's index in the NodeList):

- If `entry.intersectionRatio >= 0.25`: set `currentVisibleIndex = currentIndex`, then loop over ALL titles and tween each with `gsap.to(title, { y: index === currentIndex ? 0 : -200, duration: 0.5, ease: "power2.out", overwrite: true })`. So the active slide's title slides down/up into view (y: -200 → 0) while every other title retracts back up (y → -200).
- Else if `entry.intersectionRatio < 0.25` **and** `currentVisibleIndex === currentIndex` (the active slide is leaving): set `prevIndex = currentIndex - 1`, `currentVisibleIndex = prevIndex >= 0 ? prevIndex : null`, and tween all titles with `y: index === prevIndex ? 0 : -200`, same `duration: 0.5`, `ease: "power2.out"`, `overwrite: true`. This handles scrolling back up: the previous slide's title returns.

Only one title is ever visible at a time; the 0.25 visibility threshold is what flips them.

### 5. The pinned horizontal scroll (single ScrollTrigger, no timeline)

```
ScrollTrigger.create({
  trigger: stickySection,   // section.sticky
  start: "top top",
  end: `+=${stickyHeight}px`,  // +=6 viewport heights
  scrub: 1,                    // 1s catch-up smoothing
  pin: true,
  pinSpacing: true,
  onUpdate: (self) => { ... }
});
```

Inside `onUpdate` (all writes use `gsap.set`, i.e. immediate, no tweens — the scrub + Lenis provide the smoothing):

1. `mainMove = self.progress * totalMove`; move the track: `gsap.set(slidesContainer, { x: -mainMove })`. Progress 0 → 1 maps to translateX 0 → −(4 × slideWidth), so the strip slides fully left through all 5 slides.
2. Compute `currentSlide = Math.floor(mainMove / slideWidth)` and `slideProgress = (mainMove % slideWidth) / slideWidth` (0→1 within the current slide transition).
3. **Image parallax** — for each slide's `<img>`:
   - If the slide is `currentSlide` or `currentSlide + 1`:
     `relativeProgress = (index === currentSlide) ? slideProgress : slideProgress - 1`, then
     `parallaxAmount = relativeProgress * slideWidth * 0.25`, and
     `gsap.set(image, { x: parallaxAmount, scale: 1.35 })`.
     Effect: the outgoing (`currentSlide`) image drifts right 0 → +25% of a slide width as it exits left, and the incoming (`currentSlide + 1`) image starts at −25% and settles to 0 — a classic opposing-parallax handoff (images move at 25% of the track speed, in the opposite visual direction).
   - The handoff is seamless: when `slideProgress` reaches 1, `currentSlide` increments and the just-arrived image is already at `x: 0`, so there is no jump. Over its full two-slide lifetime (entering as "next", exiting as "current") each image travels a total of `0.5 × slideWidth` against the scroll.
   - Any other slide: `gsap.set(image, { x: 0, scale: 1.35 })`.
   - `scale: 1.35` is always maintained so the shifted image never reveals its edges.

No SplitText, CustomEase, or Three.js. The only tweened animation is the title reveal (power2.out, 0.5s); everything scroll-driven is direct `gsap.set` inside `onUpdate`.

## Assets / images

5 full-bleed interior-architecture photographs, landscape orientation (roughly 16:9, large enough to cover a full viewport at 1.35× scale), one per slide, matching the slide titles in mood:

1. A refined reception/lobby interior in warm neutral tones.
2. A modern, practical-luxury living space.
3. An interior featuring raw concrete surfaces with warm material details.
4. An interior with curved architectural elements and flowing forms.
5. A minimal interior bathed in natural light.

No logos or brand marks in the images.

## Behavior notes

- The whole strip is scroll-scrubbed and fully reversible: scrolling back up plays everything backwards (including the title swaps, handled by the observer's "leaving" branch).
- Measurements are taken once on load (no resize handler needed for the demo).
- The pin lasts 6 viewport heights of scrolling, then the page unpins and the dark outro section scrolls into view normally.
- Desktop-first showcase; no reduced-motion variant required.
