# Icon-to-Text Pinned Scroll Story (GSAP + Lenis)

## Goal

Build a full-screen pinned hero section driven by a single scrubbed ScrollTrigger: a bottom row of five app icons rises with a staggered catch-up motion, gathers and shrinks to the viewport center while the background flips from dark to light, then the icons are cloned and fly one-by-one along an L-shaped path (vertical, then horizontal) into inline placeholder slots inside a big headline, whose text segments finally fade in one at a time in a shuffled random order.

## Tech

Vanilla HTML/CSS/JS with ES module imports. Use `gsap` (npm) plus the GSAP plugin `ScrollTrigger`, and `lenis` for smooth scrolling. No other libraries. Wrap all JS in a `DOMContentLoaded` listener and call `gsap.registerPlugin(ScrollTrigger)`.

## Layout / HTML

- `<section class="hero">` containing:
  - `<div class="hero-header">` with an `<h1>` "MotionpromptsPRO" and a `<p>` "One subscription, endless web design."
  - `<div class="animated-icons">` with five children `<div class="animated-icon icon-1">` … `icon-5`, each wrapping an `<img>` (the five icon images).
  - `<h1 class="animated-text">` containing an interleaved sequence of inline elements, in this exact order:
    1. `<div class="placeholder-icon"></div>`
    2. `<span class="text-segment">Delve into coding</span>`
    3. `<div class="placeholder-icon"></div>`
    4. `<span class="text-segment">without clutter.</span>`
    5. `<span class="text-segment">Unlock source code </span>`
    6. `<div class="placeholder-icon"></div>`
    7. `<span class="text-segment">for every tutorial</span>`
    8. `<div class="placeholder-icon"></div>`
    9. `<span class="text-segment">published on the Motionprompts</span>`
    10. `<div class="placeholder-icon"></div>`
    11. `<span class="text-segment">YouTube channel.</span>`

    That is 6 text segments and 5 placeholders total.
- `<section class="outro">` with an `<h1>` "Link in description".

## Styling

- Google Font **"Host Grotesk"** on `body` (fallback sans-serif). Global reset (`* { margin:0; padding:0; box-sizing:border-box }`).
- `img { width:100%; height:100%; object-fit:cover; }`
- `h1 { font-size:7vw; font-weight:800; line-height:1; }`, `p { font-size:1.5rem; font-weight:400; }`
- `section`: `position:relative; width:100vw; height:100svh; padding:1.5rem; display:flex; align-items:center; justify-content:center; background-color:#141414; color:#e3e3db; overflow:hidden;`
- `.hero`: `flex-direction:column;` and crucially `transition: background-color 0.3s ease;` (the JS swaps its background between `#141414` and `#e3e3db`).
- `.hero-header`: `position:absolute; top:35%; left:50%; transform:translate(-50%,-50%); width:60%; text-align:center; display:flex; flex-direction:column; gap:2rem; will-change:transform,opacity;`
- `.animated-icons`: `position:fixed; bottom:1rem; left:1rem; right:1rem; display:flex; align-items:center; gap:1rem; z-index:2; will-change:transform;` — a full-width row pinned to the bottom of the viewport.
- `.animated-icon`: `flex:1; aspect-ratio:1;` (five equal squares filling the row).
- `.animated-text`: `position:relative; max-width:1000px; text-align:center; color:#141414; font-size:clamp(2rem,5vw,4rem); font-weight:800; line-height:1;` — note the text is DARK (`#141414`), so it is invisible against the dark background and only becomes readable once the background flips to light.
- `.text-segment { opacity:0; }`
- `.placeholder-icon`: `display:inline-block; width:60px; height:60px; margin-top:-10px; vertical-align:middle; visibility:hidden; will-change:transform;` — invisible but reserves inline space inside the headline where the flying icons will land.
- Media query `@media (max-width:1000px)`: `h1 { font-size:12vw; text-align:center; }`, `p { font-size:1.1rem; }`, `.hero-header { top:45%; width:100%; }`, `.placeholder-icon { width:30px; height:30px; margin-top:-4px; }`.

## GSAP effect (exhaustive)

### Lenis setup

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

### Pre-computed values (once, on load)

- Collect the 6 `.text-segment` elements into an array of `{ segment, originalIndex }` objects and **shuffle it with a Fisher–Yates shuffle** (random per page load). This shuffled order drives the final text reveal.
- `isMobile = window.innerWidth <= 1000`; `headerIconSize = isMobile ? 30 : 60` (px — matches the placeholder CSS size).
- `currentIconSize = ` width of the first `.animated-icon` via `getBoundingClientRect()`.
- `exactScale = headerIconSize / currentIconSize` — the scale factor that makes a row icon exactly placeholder-sized.

### ScrollTrigger

One single ScrollTrigger, no timelines or tweens — **everything is set imperatively with `gsap.set` inside `onUpdate`**, mapped from `self.progress` (fully deterministic and reversible):

```
ScrollTrigger.create({
  trigger: ".hero",
  start: "top top",
  end: `+=${window.innerHeight * 8}px`,   // pinned for 8 viewport heights
  pin: true,
  pinSpacing: true,
  scrub: 1,
  onUpdate: (self) => { ... }
});
```

At the top of every `onUpdate`, first `gsap.set` every `.text-segment` to `opacity: 0` (they are re-revealed only in phase 4). Then branch on `progress`:

### Phase 1 — icons rise (progress 0 → 0.3)

- `moveProgress = progress / 0.3`.
- Header fade: for `progress <= 0.15`, `headerProgress = progress / 0.15`; set `.hero-header` to `transform: translate(-50%, calc(-50% + ${-50 * headerProgress}px))` and `opacity: 1 - headerProgress` (fades up 50px and out during the first half of the phase). For `0.15 < progress <= 0.3`, hold it at `translate(-50%, calc(-50% + -50px))`, `opacity: 0`.
- If clone icons from a later phase exist (stored on `window.duplicateIcons`), remove them from the DOM and null the reference (this makes scrolling back up clean).
- Container: `gsap.set(".animated-icons", { x: 0, y: -window.innerHeight * 0.3 * moveProgress, scale: 1, opacity: 1 })` — the whole row travels upward by 30% of the viewport height across the phase.
- **Staggered catch-up per icon**: for each of the 5 `.animated-icon`s (index `i`):
  - `staggerDelay = i * 0.1`; the icon's active window is `[staggerDelay, staggerDelay + 0.5]` within `moveProgress`.
  - `iconProgress = gsap.utils.mapRange(iconStart, iconEnd, 0, 1, moveProgress)`, clamped to [0, 1].
  - Set the icon's individual `y` to `(-containerMoveY) * (1 - clampedProgress)` where `containerMoveY` is the container's current negative y. Effect: each icon starts offset downward exactly canceling the container's upward motion, then catches up in sequence — leftmost first — creating a wave.

### Phase 2 — gather to center + theme flip (progress 0.3 → 0.6)

- `scaleProgress = (progress - 0.3) / 0.3`.
- Keep the header held at `-50px`, `opacity: 0`.
- **Background flip**: when `scaleProgress >= 0.5` set `heroSection.style.backgroundColor = "#e3e3db"` (light), else `"#141414"` (dark). The CSS transition makes it a smooth 0.3s crossfade. This flip reveals the dark headline text color context for later.
- Remove `window.duplicateIcons` if present (same cleanup as phase 1).
- Move the container to the viewport center: measure the container's live `getBoundingClientRect()` center each update, compute `deltaX/deltaY` to the viewport center (`innerWidth/2`, `innerHeight/2`), multiply by `scaleProgress`, and set `{ x: deltaX, y: -window.innerHeight * 0.3 + deltaY, scale: 1 + (exactScale - 1) * scaleProgress, opacity: 1 }`. So the row shrinks from full width down to placeholder icon size while drifting to the exact center.
- Reset each individual icon to `{ x: 0, y: 0 }`.

### Phase 3 — clones fly into the headline (progress 0.6 → 0.75)

- `moveProgress = (progress - 0.6) / 0.15`.
- Header stays hidden; background stays light (`#e3e3db`).
- Park the real container at the exact viewport center (full deltas, `scale: exactScale`) but with `opacity: 0` — it is hidden and replaced by clones.
- **Create clones once** (guard with a `window.duplicateIcons` null-check): for each `.animated-icon`, `cloneNode(true)`, class `duplicate-icon`, `position: absolute`, width/height `headerIconSize`px, appended to `document.body`; store them in `window.duplicateIcons`.
- Each of the 5 clones flies from its source icon's current center (from `getBoundingClientRect()` + `window.pageXOffset/pageYOffset`, i.e. page coordinates) to the center of the matching `.placeholder-icon` (same index, also in page coordinates). The path is an **L-shape driven by `moveProgress`**:
  - First half (`moveProgress <= 0.5`): move **vertically only** — `currentY = moveY * (moveProgress / 0.5)`, `currentX = 0`.
  - Second half: vertical done (`currentY = moveY`), move **horizontally** — `currentX = moveX * ((moveProgress - 0.5) / 0.5)`.
  - Position each clone with `style.left/top = final page coords - headerIconSize / 2` (centered), `opacity: 1`, `display: flex`.

### Phase 4 — text reveal (progress 0.75 → 1)

- Header held at `translate(-50%, calc(-50% + -100px))`, `opacity: 0`; background light; real icon container `opacity: 0`.
- Snap every clone exactly onto its placeholder's center (recomputed each update so it tracks layout).
- **Shuffled per-segment fade-in**: iterate the shuffled array; for shuffled position `r`, the segment's window is `segmentStart = 0.75 + r * 0.03`, `segmentEnd = segmentStart + 0.015`. Map global `progress` through `gsap.utils.mapRange(segmentStart, segmentEnd, 0, 1, progress)`, clamp to [0, 1], and set it as the segment's `opacity`. Result: the six headline fragments pop in quickly (each over 1.5% of scroll), one after another, in random order, finishing around progress 0.9.

## Assets / images

Five square (1:1) app-style icons on colored tiles, used in order in the bottom row:

1. Green rounded-square tile with a black interlocking pinwheel / eight-point star mark.
2. Yellow circular badge with a black abstract triangular "A" mark made of two angled strokes over a bar.
3. Blue rounded-square tile with a black yin-yang-style droplet spiral inside a thin ring.
4. Orange circular badge with four black rounded squares arranged in a diamond/plus layout.
5. Periwinkle rounded-square tile with a black rounded frame containing a 2×2 grid of circles.

Any set of five colorful, flat, app-icon-style square images works — they just need to read clearly at both large (row) and small (60px inline) sizes.

## Behavior notes

- The whole effect is scrub-driven state (`gsap.set` from `progress`), so scrolling backwards fully reverses it; the clone cleanup in phases 1–2 guarantees no orphaned duplicates when scrubbing back.
- The random segment order is shuffled once per page load, so each visit reveals the headline words in a different order.
- Mobile (≤1000px) uses 30px placeholders/clones instead of 60px and the adjusted typography above; everything else is identical.
- The outro section provides the scroll runway exit after the pin ends.
