# Giant Stretching Text Scroll Animation

## Goal

Build a scroll-driven typographic page: a sequence of full-screen pinned sections where a single giant uppercase word vertically stretches open (scaleY from 0 to the exact scale that fills the viewport) as the section scrolls into view, then collapses back to 0 while pinned. The final section instead blows the whole word block up 10x, cross-fades its dark backdrop away to reveal a full-bleed background photo, and finishes with a headline that reveals word-by-word via SplitText.

## Tech

Vanilla HTML/CSS/JS with ES module imports. Use `gsap` (npm) with the GSAP plugins `ScrollTrigger` and `SplitText`, plus `lenis` for smooth scrolling. No build framework needed beyond a Vite-style dev server that resolves npm imports.

## Layout / HTML

Five stacked `<section>` elements, each `100vw` x `100svh`:

1. `<section class="hero">` — `<h1>This space intentionally loud</h1>`
2. `<section class="sticky-text-1">` — `<div class="text-container"><h1>Overdrive</h1></div>`
3. `<section class="sticky-text-2">` — `<div class="text-container"><h1>Static</h1></div>`
4. `<section class="sticky-text-3">` — three children, in this order:
   - `<div class="bg-img"><img src="..." alt=""></div>` (the background photo)
   - `<div class="text-container"><h1>Friction</h1></div>`
   - `<div class="header"><h1>Overdrive always breaks the system</h1></div>`
5. `<section class="outro">` — `<h1>End of transmission</h1>`

Load the script with `<script type="module" src="./script.js">`.

## Styling

- Palette as CSS custom properties on `:root`: `--dark: rgba(17, 39, 11, 1)` (near-black green) and `--light: rgba(162, 255, 91, 1)` (acid lime green).
- Font: "Roboto Condensed" (Google Fonts, full variable weight range), applied to `body`. Global reset (`* { margin:0; padding:0; box-sizing:border-box }`).
- All `h1`: `text-transform: uppercase; font-size: 5rem; font-weight: 900; letter-spacing: -0.02em; line-height: 0.85; text-align: center;`.
- `img { width:100%; height:100%; object-fit: cover; }`.
- Every `section`: `position: relative; width: 100vw; height: 100svh; overflow: hidden;`.
- `.hero` and `.outro`: flex-centered, background `var(--dark)`, color `var(--light)`, their `h1` at `width: 50%`.
- `.sticky-text-1` and `.sticky-text-2`: background `var(--light)`, color `var(--dark)`.
- `.sticky-text-3`: color `var(--light)`; its `.text-container` additionally gets `background-color: var(--dark)` (this dark plate is what fades out later).
- `.text-container` and `.bg-img`: `position: absolute; inset: 0 (top:0; left:0); width:100%; height:100%; will-change: opacity, transform; z-index: 1;`.
- `.text-container h1`: `position: relative; left: -0.035em; letter-spacing: -0.05em; transform-origin: 50% 0%; transform: scaleY(0);` — critical: the words start vertically collapsed and stretch from the TOP edge.
- Per-section word sizes: `.sticky-text-1 h1` = `23vw`, `font-weight: 300`; `.sticky-text-2 h1` = `35vw` (weight 900); `.sticky-text-3 .text-container h1` = `27vw`, weight 900. Add `will-change: transform` on the first two.
- `.header`: `position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 50%; z-index: 2;` (sits above the text-container).
- Media query `max-width: 1000px`: base `h1` drops to `3rem`; `.hero h1`, `.outro h1` and `.header` get `width: calc(100% - 4rem)`.

## GSAP effect (exhaustive)

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

### Lenis smooth scroll

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

### SplitText setup

Split the `.header h1` with `SplitText.create(header, { type: "words", wordsClass: "spotlight-word" })` and immediately `gsap.set(split.words, { opacity: 0 })` so the headline starts invisible.

### Dynamic target scale

For each of the three sticky sections compute `targetScales[i] = section.offsetHeight / h1.offsetHeight` (viewport height divided by the rendered text height). Recompute on `window.resize`. This is the scaleY value at which the stretched word exactly fills the section top-to-bottom. Scaling is applied by writing the inline style directly: `element.style.transform = "scaleY(" + scale + ")"` inside ScrollTrigger `onUpdate` callbacks (no tweens — everything is progress-mapped).

### Sections 1 and 2 — stretch in, collapse while pinned

Each of `.sticky-text-1` and `.sticky-text-2` gets TWO ScrollTriggers:

1. **Entry (stretch open):** `trigger: section, start: "top bottom", end: "top top", scrub: 1`. In `onUpdate`, set the h1's `scaleY = targetScale * self.progress` — the word grows from 0 to full viewport height as the section travels up into view.
2. **Pinned (collapse):** `trigger: section, start: "top top", end: "+=" + window.innerHeight + "px", pin: true, pinSpacing: false, scrub: 1`. In `onUpdate`, set `scaleY = targetScale * (1 - self.progress)` — the word squashes back to 0 over one extra viewport of scroll. Because `pinSpacing` is false, the next section slides up and covers the pinned one while it collapses.

### Section 3 — stretch in, then 10x blow-up reveal

1. **Entry:** identical pattern — `trigger: ".sticky-text-3", start: "top bottom", end: "top top", scrub: 1`, `onUpdate` sets the h1 `scaleY = targetScales[2] * self.progress`.
2. **Pinned mega-zoom:** `trigger: ".sticky-text-3", start: "top top", end: "+=" + (window.innerHeight * 4) + "px", pin: true, pinSpacing: true, scrub: 1`. All choreography lives in `onUpdate`, mapped to `self.progress` (0→1 across 4 viewport heights), manipulating the whole `.text-container` (not the h1) via inline styles:
   - **Scale phase, progress 0 → 0.75:** `scaleProgress = progress / 0.75`; apply `transform: scale3d(s, s, 1)` with `s = 1 + 9 * scaleProgress` (i.e. 1 → 10). For progress > 0.75 clamp at `scale3d(10, 10, 1)`.
   - **Backdrop fade, progress 0.25 → 0.5:** the container's `background-color` stays fully opaque `rgba(17, 39, 11, 1)` below 0.25, then its alpha lerps 1 → 0 linearly across [0.25, 0.5] (`alpha = 1 - (progress - 0.25) / 0.25`, clamped 0..1), and stays alpha 0 above 0.5 — revealing the full-bleed photo underneath.
   - **Text fade, progress 0.5 → 0.75:** the container's `opacity` lerps 1 → 0 across [0.5, 0.75] (`opacity = 1 - (progress - 0.5) / 0.25`); stays 0 above 0.75.
   - **At progress 0 reset:** background back to opaque dark, opacity back to 1.
   - **Headline word reveal, progress 0.75 → 0.95:** map `textProgress = (progress - 0.75) / 0.2`. For each SplitText word at `index`, show it with a hard cut (no fade tween): `opacity = textProgress >= index / totalWords ? 1 : 0` via `gsap.set(word, { opacity })` — words pop in sequentially left-to-right. Below 0.75 force all words `opacity: 0`; above 0.95 force all `opacity: 1`.

No eases, durations or staggers apply anywhere — every value is a pure linear function of ScrollTrigger progress with `scrub: 1` providing ~1s of smoothing/lag.

## Assets / images

One image: a full-bleed background photograph (landscape orientation, roughly 16:9 or wider, moody/atmospheric works well) that fills the third section behind the dark plate and is revealed as the plate fades out. It is displayed with `object-fit: cover` inside an absolutely positioned full-viewport wrapper.

## Behavior notes

- The whole page hijacks native scroll via Lenis; ScrollTrigger updates on Lenis's `scroll` event.
- Target scales recompute on resize so words always stretch exactly to viewport height.
- Sections 1–2 use `pinSpacing: false` (next section overlaps them); section 3 uses `pinSpacing: true` and consumes 4 extra viewport heights of scroll.
- Total flow: hero → word 1 stretches/collapses → word 2 stretches/collapses → word 3 stretches, zooms 10x, dark plate fades, photo reveals, headline appears word-by-word → outro.
