# Telescope Image Scroll Animation

## Goal

Build a scroll-driven "telescope" hero: a pinned full-screen banner where an image container scales up from 0 while six stacked copies of the same photo — each clipped by a silhouette-shaped CSS mask — grow at staggered scales, producing a lens-within-a-lens telescoping depth effect. Two large intro words slide apart horizontally and a centered headline fades in word-by-word near the end of the scrub.

## Tech

Vanilla HTML/CSS/JS with ES module imports. Use `gsap` (npm), plus the GSAP plugins `ScrollTrigger` and `SplitText`, and `lenis` for smooth scrolling. No frameworks, no build-specific code — plain `import` statements at the top of `script.js`.

## Layout / HTML

Three full-viewport sections in `<body>`:

1. `<section class="hero">` — a centered `<h1>` with the text "The frame is only the beginning."
2. `<section class="banner">` — the animated section. Inside:
   - `<div class="banner-img-container">` containing, in this order:
     - **7 image layers**, each `<div class="img"><img src="..." alt="" /></div>`. All 7 use the **same photo**. The **first** layer has only class `img` (the unmasked base). The remaining **6** have class `img mask` (silhouette-masked copies).
     - `<div class="banner-header"><h1>The Season Wears Confidence</h1></div>` — the headline that reveals word-by-word.
   - `<div class="banner-intro-text-container">` with two children:
     - `<div class="banner-intro-text"><h1>Surface</h1></div>`
     - `<div class="banner-intro-text"><h1>Layered</h1></div>`
3. `<section class="outro">` — a centered `<h1>` with the text "And that’s the silhouette."

Load `script.js` with `<script type="module" src="./script.js"></script>` at the end of `<body>`.

## Styling

- Google Font **Instrument Serif** (`@import` in the CSS, weights regular + italic available; only regular is used). `body { font-family: "Instrument Serif", sans-serif; }`.
- Global reset: `* { margin: 0; padding: 0; box-sizing: border-box; }`.
- `img { width: 100%; height: 100%; object-fit: cover; will-change: transform; }`.
- `h1 { font-size: 4rem; line-height: 1.1; }`.
- Every `section`: `position: relative; width: 100vw; height: 100svh; background-color: #e3e3db; color: #141414; overflow: hidden;`.
- `.hero` and `.outro`: flex, centered both axes; their `h1` is `width: 50%; text-align: center;`.
- `.banner-img-container`: `position: relative; width: 100%; height: 100%; will-change: transform;`.
- `.banner-img-container .img`: `position: absolute; top: 0; left: 0; width: 100%; height: 100%; will-change: transform;` (all 7 layers stack on top of each other).
- `.banner-img-container .img.mask`: apply the silhouette PNG as a CSS mask —
  `mask-image: url(<mask png>)` (plus `-webkit-mask-image`), `mask-size: cover`, `mask-position: center` (with `-webkit-` prefixed equivalents). The masked copies only show the photo through the silhouette shape.
- `.banner-header`: `position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 75%; text-align: center; color: #e3e3db; z-index: 2;` (light text over the photo).
- `.banner-intro-text-container`: `position: absolute; top: 50%; transform: translateY(-50%); width: 100%; display: flex; gap: 0.5rem; z-index: 10;`.
- `.banner-intro-text`: `flex: 1; position: relative; will-change: transform;`. The first one gets `display: flex; justify-content: flex-end;` so the two words ("Surface" / "Layered") sit side by side at the exact horizontal center of the viewport, separated only by the 0.5rem gap.
- Media query `@media (max-width: 1000px)`: `.hero h1`, `.outro h1` and `.banner-header` become `width: calc(100% - 4rem);`.

## GSAP effect (exhaustive)

Everything runs inside a `DOMContentLoaded` listener.

### Setup

1. `gsap.registerPlugin(ScrollTrigger, SplitText)`.
2. **Lenis wiring** (standard pattern):
   ```
   const lenis = new Lenis();
   lenis.on("scroll", ScrollTrigger.update);
   gsap.ticker.add((time) => { lenis.raf(time * 1000); });
   gsap.ticker.lagSmoothing(0);
   ```
3. Grab references:
   - `bannerContainer` = `.banner-img-container`
   - `bannerIntroTextElements` = `gsap.utils.toArray(".banner-intro-text")` (2 elements)
   - `bannerMaskLayers` = `gsap.utils.toArray(".mask")` (the 6 masked layers, in DOM order)
   - `bannerHeader` = `.banner-header h1`
4. **SplitText**: `new SplitText(bannerHeader, { type: "words" })`; keep `splitText.words`. Immediately `gsap.set(words, { opacity: 0 })`.
5. **Initial states**:
   - Each mask layer `i` (0-based over the 6 masked layers): `gsap.set(layer, { scale: 0.9 - i * 0.2 })` → scales 0.9, 0.7, 0.5, 0.3, 0.1, −0.1. (Yes, the last value is negative — keep the formula exactly; it's what creates the deepest layer popping in late.)
   - `gsap.set(bannerContainer, { scale: 0 })` — the whole banner starts collapsed to a point.

### ScrollTrigger

One single `ScrollTrigger.create({...})` — **no tweens, no timeline**. All motion is applied imperatively with `gsap.set` inside `onUpdate` from `self.progress` (a scrubbed progress value 0→1):

```
trigger: ".banner",
start: "top top",
end: `+=${window.innerHeight * 4}px`,   // pinned for 4 viewport heights
pin: true,
pinSpacing: true,
scrub: 1,
```

### onUpdate logic (per frame, `progress` = `self.progress`)

1. **Container zoom**: `gsap.set(bannerContainer, { scale: progress })` — linear 0 → 1 across the whole pin distance.
2. **Telescoping mask layers**: for each masked layer `i`:
   - `initialScale = 0.9 - i * 0.2`
   - `layerProgress = Math.min(progress / 0.9, 1.0)` (layers finish converging at 90% of the scrub)
   - `currentScale = initialScale + layerProgress * (1.0 - initialScale)`
   - `gsap.set(layer, { scale: currentScale })`
   - Net effect: every layer interpolates linearly from its staggered initial scale to exactly 1.0 by `progress = 0.9`, so the silhouette copies converge onto the base image.
3. **Intro words slide apart** (only while `progress <= 0.9`):
   - `textProgress = progress / 0.9`
   - `moveDistance = window.innerWidth * 0.5`
   - first intro text: `gsap.set(el, { x: -textProgress * moveDistance })` (moves left, off-screen)
   - second intro text: `gsap.set(el, { x: textProgress * moveDistance })` (moves right, off-screen)
4. **Headline word-by-word reveal** (window `0.7 <= progress <= 0.9`):
   - `headerProgress = (progress - 0.7) / 0.2` (normalized 0→1 inside the window)
   - For each word `i` of `totalWords`:
     - `wordStartDelay = i / totalWords`, `wordEndDelay = (i + 1) / totalWords`
     - if `headerProgress >= wordEndDelay` → opacity 1
     - else if `headerProgress >= wordStartDelay` → opacity = `(headerProgress - wordStartDelay) / (wordEndDelay - wordStartDelay)` (linear fade within the word's own slice)
     - else opacity 0
     - Apply with `gsap.set(word, { opacity: ... })` — a sequential left-to-right cascade fully driven by scroll.
   - Guard rails: if `progress < 0.7` set all words to opacity 0; if `progress > 0.9` set all words to opacity 1 (so scrolling backwards/forwards past the window snaps correctly).

There are no eases, durations, delays or staggers anywhere — every value is a direct linear function of scroll progress (the only smoothing comes from `scrub: 1` and Lenis inertia).

## Assets / images

- **1 editorial photo** (JPG): a full-bleed studio portrait of a single person against a bold solid-color backdrop, roughly 4:5/portrait framing, displayed with `object-fit: cover` filling the viewport. This same file is used in all 7 image layers.
- **1 silhouette alpha mask** (PNG): the silhouette of the same figure (head, hair, shoulders, arms) as a solid opaque shape on a fully transparent background. It is applied via CSS `mask-image` with `mask-size: cover; mask-position: center`, so only the silhouette area of the masked photo copies is visible. For the effect to read well, the silhouette should roughly match the subject's pose/position in the photo.

## Behavior notes

- The banner is pinned for 4 extra viewport heights (`pinSpacing: true`), so total page scroll ≈ hero (100svh) + banner pin (100svh + 4×innerHeight) + outro (100svh).
- Scrolling back up fully reverses everything (all state is derived from `progress`).
- `will-change: transform` on the container, layers, images and intro texts keeps the constant `gsap.set` scaling smooth.
- `end` and `moveDistance` read `window.innerHeight`/`window.innerWidth` once at creation; no resize handling is required.
- Works on mobile (only the text widths change under 1000px).
