# Capsules Animated Sticky Columns — scroll-pinned phased column swap

## Goal

Build a full-page scroll experience with a pinned full-screen section containing four rounded "capsule" columns (two text capsules, two image capsules). As the user scrolls through the pinned section, the columns swap in two discrete phases: new capsules slide in from the right/bottom while the previous one fades and scales away, an image is revealed with a growing clip-path wipe, and a text block swaps via SplitText line masks. Scrolling back up reverses each phase.

## Tech

Vanilla HTML/CSS/JS with ES module imports. Use `gsap` (npm) plus the GSAP plugins `ScrollTrigger` and `SplitText`, and `lenis` (npm) for smooth scrolling. Everything runs inside a `DOMContentLoaded` listener. Register plugins with `gsap.registerPlugin(ScrollTrigger, SplitText)`.

Lenis wiring (exact pattern):

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

## Layout / HTML

Three sections, each `100vw` × `100svh`:

1. `<section class="intro">` — a single centered `<h1>` (e.g. "We create modern interiors that feel effortlessly personal.").
2. `<section class="sticky-cols">` — the pinned section. Inside it a `<div class="sticky-cols-wrapper">` containing four absolutely-positioned columns:
   - `<div class="col col-1">` → `<div class="col-content">` → `<div class="col-content-wrapper">` with an `<h1>` (e.g. "We design spaces where comfort meets quiet sophistication.") and a `<p>` (a two-sentence supporting paragraph about layered textures and thoughtful details).
   - `<div class="col col-2">` → two stacked image capsules: `<div class="col-img col-img-1">` and `<div class="col-img col-img-2">`, each containing `<div class="col-img-wrapper">` → `<img>` (image 1 and image 2).
   - `<div class="col col-3">` → two stacked text blocks: `<div class="col-content-wrapper">` (an `<h1>` like "Our interiors are crafted to feel as calm as they look." + a supporting `<p>`) and `<div class="col-content-wrapper-2">` (an `<h1>` like "Every detail is chosen to bring ease and elegance into your space." + a supporting `<p>`).
   - `<div class="col col-4">` → `<div class="col-img">` → `<div class="col-img-wrapper">` → `<img>` (image 3).
3. `<section class="outro">` — a single centered `<h1>` (e.g. "Timeless design begins with a conversation.").

## Styling

- Google Font "DM Sans" (import via Google Fonts `@import`), applied to `body`. Sans-serif fallback.
- CSS variables: `--bg: #141414` (page/section background), `--bg-200: #282828` (capsule background), `--fg: #fff`, `--fg-100: #f1f1f1`, `--fg-200: #a1a1a1`.
- Global reset: `* { margin: 0; padding: 0; box-sizing: border-box; }`.
- `section { position: relative; width: 100vw; height: 100svh; background: var(--bg); color: var(--fg-100); overflow: hidden; }`.
- Type: `h1 { font-size: 2.5rem; font-weight: 500; line-height: 1.1; }`, `p { font-size: 1rem; font-weight: 500; }`. Intro/outro sections are flex-centered and their `h1` is `width: 50%; text-align: center;`.
- `img { width: 100%; height: 100%; object-fit: cover; }`.
- `.sticky-cols { padding: 0.5rem; }`, `.sticky-cols-wrapper { position: relative; width: 100%; height: 100%; }`.
- `.col { position: absolute; width: 50%; height: 100%; will-change: transform; }`. Inside columns: `h1 { color: var(--fg-200); width: 60%; }`, `p { color: var(--fg-100); width: 60%; }`.
- Initial offsets (plain CSS transforms — these are the "off-stage" positions):
  - `.col-2 { transform: translateX(100%); }` (right half, on screen).
  - `.col-3 { transform: translateX(100%) translateY(100%); padding: 0.5rem; }` (off-screen bottom-right).
  - `.col-4 { transform: translateX(100%) translateY(100%); }` (off-screen bottom-right).
  - `.col-1` has no offset (left half, on screen).
- Capsule look: `.col-content, .col-img { position: relative; width: 100%; height: 100%; padding: 0.5rem; }`; `.col-content-wrapper, .col-img-wrapper { position: relative; width: 100%; height: 100%; background: var(--bg-200); border-radius: 3rem; overflow: hidden; }`. `.col-content-wrapper` also gets `padding: 2.5rem; display: flex; flex-direction: column; justify-content: space-between;` (h1 at top, p at bottom).
- `.col-content-wrapper-2 { position: absolute; top: 0; left: 0; width: 100%; height: 100%; padding: 2.5rem; display: flex; flex-direction: column; justify-content: space-between; }` — it sits exactly on top of col-3's first wrapper (note: no background of its own).
- Stacked images in col-2: `.col-img-1, .col-img-2 { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }`.
- `.col-img-2 { clip-path: polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%); }` — a zero-height sliver at the top, so image 2 starts fully hidden.
- `.col-img-2 img { scale: 1.25; }` — image 2 starts zoomed in.
- Line-mask helpers for SplitText: `.line { overflow: hidden; }` and `.line span { display: block; will-change: transform; }`.
- Media query `@media (max-width: 1000px)`: `h1 { font-size: 1.25rem; }`, `p { font-size: 0.85rem; }`, `.col h1, .col p { width: 100%; }`, wrappers' padding drops to `2rem`.

## GSAP effect (the core — follow exactly)

### 1. SplitText setup (col-3 text swap)

On init, split every `.col-3 h1, .col-3 p` element with `new SplitText(element, { type: "lines", linesClass: "line" })`. Then, for each resulting line element, manually wrap its text in a span: `line.innerHTML = `<span>${line.textContent}</span>``. Combined with the `.line { overflow: hidden }` CSS this creates per-line masks.

Initial states (gsap.set):

- `.col-3 .col-content-wrapper .line span` → `{ y: "0%" }` (first text block visible).
- `.col-3 .col-content-wrapper-2 .line span` → `{ y: "-125%" }` (second text block hidden above its line masks).

### 2. ScrollTriggers

Two separate ScrollTriggers on the same trigger element `.sticky-cols`:

- **Pin trigger:** `{ trigger: ".sticky-cols", start: "top top", end: `+=${window.innerHeight * 5}px`, pin: true, pinSpacing: true }`. No animation attached — it only pins.
- **Progress trigger:** `{ trigger: ".sticky-cols", start: "top top", end: `+=${window.innerHeight * 6}px`, onUpdate: (self) => { ... } }`. Note the end distance is 6× viewport height (intentionally longer than the pin), so the phase thresholds at 33%/66% of this trigger land inside the pinned range.

### 3. Phase state machine (inside onUpdate)

Keep a `let currentPhase = 0` variable outside the trigger. In `onUpdate`, read `self.progress` and fire tweens ONCE per threshold crossing (not scrubbed — these are discrete `gsap.to` tweens triggered by crossing a progress value):

**Phase 0 → 1** (when `progress >= 0.33 && currentPhase === 0`; set `currentPhase = 1`). All tweens `duration: 0.75`, default ease:

- `.col-1` → `{ opacity: 0, scale: 0.75 }` (left text capsule fades/shrinks away).
- `.col-2` → `{ x: "0%" }` (image column slides from the right half to the left half).
- `.col-3` → `{ y: "0%" }` (text column rises from below into the right half — its X stays at 100% from the CSS transform).
- `.col-img-1 img` → `{ scale: 1.25 }` (first image zooms in).
- `.col-img-2` → `{ clipPath: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)" }` (second image wipes open from the top edge to full height, revealing it over image 1).
- `.col-img-2 img` → `{ scale: 1 }` (second image zooms out from 1.25 to 1 while being revealed).

**Phase 1 → 2** (when `progress >= 0.66 && currentPhase === 1`; set `currentPhase = 2`). All `duration: 0.75`:

- `.col-2` → `{ opacity: 0, scale: 0.75 }` (image column fades/shrinks away).
- `.col-3` → `{ x: "0%" }` (text column slides from right half to left half).
- `.col-4` → `{ y: "0%" }` (final image column rises from below into the right half).
- `.col-3 .col-content-wrapper .line span` → `{ y: "-125%", duration: 0.75 }` (first text block's lines exit upward through their masks).
- `.col-3 .col-content-wrapper-2 .line span` → `{ y: "0%", duration: 0.75, delay: 0.5 }` (second text block's lines drop in from above, starting 0.5s later).

**Reverse 1 → 0** (when `progress < 0.33 && currentPhase >= 1`; set `currentPhase = 0`). Mirror every phase-1 tween back to its start value, all `duration: 0.75`: `.col-1` → `{ opacity: 1, scale: 1 }`, `.col-2` → `{ x: "100%" }`, `.col-3` → `{ y: "100%" }`, `.col-img-1 img` → `{ scale: 1 }`, `.col-img-2` → `{ clipPath: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)" }`, `.col-img-2 img` → `{ scale: 1.25 }`.

**Reverse 2 → 1** (when `progress < 0.66 && currentPhase === 2`; set `currentPhase = 1`). All `duration: 0.75`: `.col-2` → `{ opacity: 1, scale: 1 }`, `.col-3` → `{ x: "100%" }`, `.col-4` → `{ y: "100%" }`, `.col-3 .col-content-wrapper .line span` → `{ y: "0%", duration: 0.75, delay: 0.5 }` (delay on the incoming block), `.col-3 .col-content-wrapper-2 .line span` → `{ y: "-125%", duration: 0.75 }`.

No custom eases anywhere — every tween uses GSAP's default ease (`power1.out`). No timelines; all tweens in a phase start simultaneously (except the noted `delay: 0.5`).

## Assets / images

3 interior-design photographs (warm, softly lit modern rooms), used as full-bleed `object-fit: cover` fills inside rounded capsules of roughly half the viewport width × full viewport height (portrait-ish crop is safest):

1. Image 1 — first image capsule in col-2, visible from the start.
2. Image 2 — second image capsule in col-2, hidden by the zero-height clip-path and revealed in phase 1.
3. Image 3 — final image capsule in col-4, slides up in phase 2.

No logos or brand marks; keep copy neutral (interior design studio voice).

## Behavior notes

- The whole animation is scroll-driven but the tweens themselves are time-based (0.75s), fired at thresholds — it should feel like snappy state changes, not scrubbing.
- Scrolling back up must reverse each phase via the mirror tweens above.
- The intro and outro sections scroll normally before/after the pinned section; pinSpacing keeps the document flow.
- Works at any viewport; below 1000px the type scales down per the media query. No reduced-motion handling required.
