# Capsule-to-Fullscreen Sticky Cards (scroll deck with marquee + SplitText reveals)

## Goal
Build a scroll-driven page with a **deck of four full-viewport cards that pin on top of each other**. The star effect: the **first card starts as a small pill-shaped "capsule"** (image wrapper scaled to 0.5 with a 400px border-radius) floating over a **giant looping horizontal text marquee**; as the user scrolls through 300vh, the capsule **grows into a full-bleed image** (scale 0.5→1, border-radius 400px→25px) while the marquee fades away, and when the expansion completes the card's **title characters slide in through overflow masks (SplitText)** together with a small description. Every following card then pins over the previous one — the outgoing card **scales down and fades**, the incoming card's image **de-zooms (scale 2→1) and sharpens its corners (border-radius 150px→25px)**, and its title/description animate in the same masked-chars way. Smooth scroll via Lenis. An intro section sits above the deck and an outro below.

## 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. No framework:

```js
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
import Lenis from "lenis";
```

Register once: `gsap.registerPlugin(SplitText, ScrollTrigger);`. Wrap all setup in a `DOMContentLoaded` listener. Put the seamless-marquee helper in a second module (`marquee.js`) exporting `setupMarqueeAnimation()`.

## Layout / HTML
Three blocks in order — intro, cards deck, outro:

```html
<section class="intro">
  <h1>We design spaces that don’t just exist.</h1>
</section>

<section class="cards">
  <div class="card">
    <div class="card-marquee">
      <div class="marquee">
        <h1>Design Beyond Boundaries</h1>
        <h1>Built for Tomorrow</h1>
        <h1>Real Impact</h1>
        <h1>Digital Visions</h1>
      </div>
    </div>
    <div class="card-wrapper">
      <div class="card-content">
        <div class="card-title"><h1>Curved Horizon</h1></div>
        <div class="card-description">
          <p>A futuristic residence that plays with curvature and flow, blending bold geometry with natural topography.</p>
        </div>
      </div>
      <div class="card-img"><img src="…image-1" alt="" /></div>
    </div>
  </div>
  <!-- cards 2–4: identical structure but WITHOUT .card-marquee -->
</section>

<section class="outro">
  <h1>Architecture reimagined for the virtual age.</h1>
</section>
```

- **Four `.card`s.** Only the first card contains the `.card-marquee > .marquee` block (four `<h1>`s). Every card has `.card-wrapper > (.card-content + .card-img > img)`, and `.card-content` holds `.card-title > h1` plus `.card-description > p`.
- Card titles / descriptions (neutral fictional architecture copy):
  1. **Curved Horizon** — "A futuristic residence that plays with curvature and flow, blending bold geometry with natural topography."
  2. **Glass Haven** — "A sleek pavilion of pure transparency, openness and light, designed to dissolve into its environment."
  3. **Moss Cube** — "A minimalist cube home crowned with a living moss dome, merging micro-architecture with ecological design."
  4. **Floating Shelter** — "This design explores an ethereal structure perched on a grassy islet, seemingly hovering above water."

## Styling
Font: Inter (Google Fonts variable import), `body { font-family: "Inter", sans-serif; }`. Dark editorial look — every `section` is `#0f0f0f` background with `#fff` text.

- Reset: `* { margin:0; padding:0; box-sizing:border-box; }`
- `img { position:relative; width:100%; height:100%; object-fit:cover; will-change:transform; }`
- `h1 { font-size:5rem; font-weight:500; letter-spacing:-0.1rem; line-height:1.25; }`
- `p { font-size:1.125rem; font-weight:400; line-height:1.25; }`
- `section { position:relative; width:100vw; background-color:#0f0f0f; color:#fff; }`
- `.intro, .outro { height:100svh; padding:1.5em; display:flex; justify-content:center; align-items:center; }` — their `h1` is `width:60%; text-align:center; line-height:1.1;`
- `.cards { position:relative; display:flex; flex-direction:column; gap:25svh; }` — the gap between cards is part of the scroll choreography.
- `.card { position:relative; width:100vw; height:100svh; padding:1.5em; }`
- `.card:nth-child(2) { margin-top:50vh; }` — extra breathing room after the intro capsule card.
- `.card-wrapper { position:relative; width:100%; height:100%; will-change:transform; }` (this is what scales/fades out)
- `.card-img { position:absolute; width:100%; height:100%; border-radius:150px; overflow:hidden; }`
- `.card-img img { transform:scale(2); }` — **CSS default zoom for the non-intro cards**; the intro card's values are overridden by GSAP.
- `.card-content { position:absolute; width:100%; height:100%; display:flex; align-items:flex-end; justify-content:center; z-index:1; }`
- `.card-title { width:100%; position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); text-align:center; }`
- `.card-description { text-align:center; width:40%; margin-bottom:3em; position:relative; transform:translateX(40px); opacity:0; }` — **starts hidden, shifted 40px right**.
- Marquee (first card only): `.card-marquee { width:100%; position:absolute; top:50%; left:0; transform:translateY(-50%); overflow:hidden; }`, `.card-marquee .marquee { display:flex; }`, `.card-marquee .marquee h1 { white-space:nowrap; font-size:10vw; font-weight:600; margin-right:30px; }`
- SplitText mask pieces: `.char { position:relative; overflow:hidden; display:inline-block; }` and `.char span { transform:translateX(100%); display:inline-block; will-change:transform; }` — **each character span starts fully off to the right inside its own overflow-hidden char box**.
- Mobile `@media (max-width: 900px)`: `h1 { font-size:2rem; letter-spacing:0; }`, intro/outro `h1 { width:100%; }`, `.card-description { width:90%; }`.

## GSAP effect (the important part — be exhaustive)

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

### 2. SplitText prep on ALL card titles
For every `.card-title h1`: `new SplitText(title, { type: "chars", charsClass: "char", tag: "div" })`. Then manually wrap each char's text in an inner span: `char.innerHTML = `<span>${char.textContent}</span>``. Result: each character is a `div.char` (overflow hidden) containing a `span` that CSS pushes to `translateX(100%)` — invisible until animated.

### 3. Intro card initial state (the capsule)
`cards = gsap.utils.toArray(".card")`, `introCard = cards[0]`. On the intro card only:
```js
gsap.set(cardImgWrapper /* .card-img */, { scale: 0.5, borderRadius: "400px" });
gsap.set(cardImg /* .card-img img */, { scale: 1.5 });
```

### 4. Shared content in/out helpers
- `animateContentIn(titleChars, description)`:
  - chars (`.char span` NodeList) → `gsap.to(chars, { x: "0%", duration: 0.75, ease: "power4.out" })`
  - description → `gsap.to(desc, { x: 0, opacity: 1, duration: 0.75, delay: 0.1, ease: "power4.out" })`
- `animateContentOut(titleChars, description)`:
  - chars → `gsap.to(chars, { x: "100%", duration: 0.5, ease: "power4.out" })`
  - description → `gsap.to(desc, { x: "40px", opacity: 0, duration: 0.5, ease: "power4.out" })`

No stagger — all chars slide together as a masked block.

### 5. Intro capsule expansion — ScrollTrigger with manual scrub via onUpdate
```js
ScrollTrigger.create({
  trigger: introCard,
  start: "top top",
  end: "+=300vh",
  onUpdate: (self) => { … }
});
```
Inside `onUpdate` (no `scrub` property — everything is driven by `self.progress` with `gsap.set`):
- `imgScale = 0.5 + progress * 0.5` → wrapper scale **0.5 → 1**
- `borderRadius = 400 - progress * 375` → **400px → 25px** (applied as a px string)
- `innerImgScale = 1.5 - progress * 0.5` → inner `<img>` scale **1.5 → 1** (counter-zoom so the photo stays framed while the mask grows)
- **Marquee fade**, keyed to `imgScale`: while `0.5 ≤ imgScale ≤ 0.75`, `opacity = 1 - (imgScale - 0.5) / 0.25` (fades 1→0 during the first half of the expansion); clamp to `1` below 0.5 and `0` above 0.75. Applied with `gsap.set(marquee, { opacity })` on the first card's `.marquee`.
- **Content reveal flag**: keep a boolean on the card (e.g. `introCard.contentRevealed`). When `progress >= 1` and it's false → set it true and call `animateContentIn` with the intro card's `.char span` spans and `.card-description`. When `progress < 1` and it's true → set false and call `animateContentOut`. This makes the title/description pop in exactly when the capsule finishes expanding, and retract if the user scrolls back.

### 6. Pinning the deck
Each card gets its own pin trigger:
```js
cards.forEach((card, index) => {
  const isLastCard = index === cards.length - 1;
  ScrollTrigger.create({
    trigger: card,
    start: "top top",
    end: isLastCard ? "+=100vh" : "top top",
    endTrigger: isLastCard ? null : cards[cards.length - 1],
    pin: true,
    pinSpacing: isLastCard,
  });
});
```
So cards 1–3 pin from their own `top top` until **the LAST card's top hits the top** (`endTrigger` = last card, `pinSpacing: false` so they stack under each other without adding scroll length), and the last card pins for an extra `+=100vh` **with** pinSpacing.

### 7. Outgoing card scale/fade
For every card except the last: a trigger watching the **next** card's approach —
```js
ScrollTrigger.create({
  trigger: cards[index + 1],
  start: "top bottom",
  end: "top top",
  onUpdate: (self) => {
    gsap.set(cardWrapper /* this card's .card-wrapper */, {
      scale: 1 - self.progress * 0.25,   // 1 → 0.75
      opacity: 1 - self.progress,        // 1 → 0
    });
  },
});
```

### 8. Incoming image morph
For every card except the first: as the card itself travels from bottom of viewport to top —
```js
ScrollTrigger.create({
  trigger: card,
  start: "top bottom",
  end: "top top",
  onUpdate: (self) => {
    gsap.set(cardImg, { scale: 2 - self.progress });                      // img zoom 2 → 1
    gsap.set(imgContainer, { borderRadius: 150 - self.progress * 125 + "px" }); // 150px → 25px
  },
});
```

### 9. Content reveal for cards 2–4
For every card except the first:
```js
ScrollTrigger.create({
  trigger: card,
  start: "top top",
  onEnter: () => animateContentIn(cardTitleChars, cardDescription),
  onLeaveBack: () => animateContentOut(cardTitleChars, cardDescription),
});
```

### 10. Infinite horizontal marquee (marquee.js)
`setupMarqueeAnimation()` collects `gsap.utils.toArray(".marquee h1")` and, if any, runs the canonical GSAP **`horizontalLoop` helper** (from GSAP's official helper functions) with `{ repeat: -1, paddingRight: 30 }`:
- builds a `gsap.timeline({ repeat: -1, defaults: { ease: "none" } })`
- speed defaults to 1 → **100 pixels per second**, linear
- measures each item's width and current `xPercent`, computes `totalWidth` (last item's offset + width + the 30px `paddingRight`), then for each item chains a `.to` (slide left to `((curX - distanceToLoop) / width) * 100` xPercent, duration `distanceToLoop / pixelsPerSecond`, inserted at time 0) followed by a `.fromTo` (re-enter from the right at `((curX - distanceToLoop + totalWidth) / width) * 100` back to its start xPercent, `immediateRender: false`, inserted at `distanceToLoop / pixelsPerSecond`) — producing a seamless, gapless infinite loop of the four headlines
- finishes with `tl.progress(1, true).progress(0, true)` to pre-render positions.

Call `setupMarqueeAnimation()` at the end of the DOMContentLoaded setup.

## Assets / images
Four **full-bleed architecture/landscape photos** (landscape orientation, ~16:9 or wider works best; they render full-viewport with `object-fit: cover`):
1. A futuristic white residence with curved, flowing organic geometry set into natural terrain (the capsule/intro card).
2. A sleek transparent glass pavilion full of light, dissolving into its surroundings.
3. A minimalist concrete cube house topped with a living green moss dome.
4. An ethereal structure on a small grassy islet that appears to float above water.

## Behavior notes
- The whole page scroll is the interaction — no clicks/hovers. Total scroll ≈ intro (100svh) + capsule expansion (300vh) + the stacked deck + outro.
- Scrolling back up fully reverses everything: capsule shrinks, marquee fades back in, titles retract through their masks (`onLeaveBack` / the `contentRevealed` flag).
- The marquee loop runs forever, independent of scroll.
- `will-change: transform` on images, `.card-wrapper` and `.char span` keeps the constant `gsap.set` updates smooth.
- Works on mobile via the 900px media query (smaller type, full-width text); no reduced-motion branch is required.
