# Telescope Scroll Animation

## Goal
Build a pinned, scroll-driven "spotlight" gallery section. As the user scrolls, two intro words ("Beneath" / "Beyond") split apart horizontally while a full-bleed background image scales up from zero behind them; then a diamond-shaped `clip-path` "telescope viewfinder" takes over: a vertical column of 10 project titles scrolls up through the viewfinder while small thumbnail images fly across the right half of the screen along a quadratic bezier arc. The title closest to the vertical center of the viewport is highlighted (full opacity) and **swaps the full-bleed background image** to its matching photo. Everything is driven by a **single ScrollTrigger** (pin + scrub over 10× viewport height, Lenis-smoothed) whose `onUpdate` maps `self.progress` across phased ranges using `gsap.set` — there is no timeline.

## Tech
Vanilla HTML/CSS/JS with ES module imports. Use **`gsap` (npm)** with the **`ScrollTrigger`** plugin, plus **`lenis`** for smooth scrolling:
```js
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";

gsap.registerPlugin(ScrollTrigger);
```
Wire Lenis to GSAP exactly like this:
```js
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
```

## Layout / HTML
Three full-viewport sections. The middle one is the animated spotlight; the titles column and the flying thumbnails are **generated by JS**, not written in the HTML:
```html
<body>
  <section class="intro">
    <h1>A curated series of surreal frames.</h1>
  </section>

  <section class="spotlight">
    <div class="spotlight-intro-text-wrapper">
      <div class="spotlight-intro-text"><p>Beneath</p></div>
      <div class="spotlight-intro-text"><p>Beyond</p></div>
    </div>

    <div class="spotlight-bg-img">
      <img src="[gallery image 1]" alt="" />
    </div>

    <div class="spotlight-titles-container">
      <div class="spotlight-titles"></div>
    </div>

    <div class="spotlight-images"></div>

    <div class="spotlight-header">
      <p>Discover</p>
    </div>
  </section>

  <section class="outro">
    <h1>Moments in still motion.</h1>
  </section>
  <script type="module" src="./script.js"></script>
</body>
```

## Styling
Global reset `* { margin:0; padding:0; box-sizing:border-box; }`. Body font: **"PP Neue Montreal"** (or a similar clean grotesque sans as fallback). `img { width:100%; height:100%; object-fit:cover; }`. Type scale: `h1 { font-size:4rem; font-weight:500; line-height:1; }`, `p { font-size:1.5rem; font-weight:500; line-height:1; }`.

- `section`: `position:relative; width:100vw; height:100svh; overflow:hidden;`
- `.intro, .outro`: flex-centered, `background-color:#0f0f0f; color:#fff;`
- `.spotlight-intro-text-wrapper`: `position:absolute; width:100%; top:50%; transform:translateY(-50%); display:flex; gap:0.5rem;` — each `.spotlight-intro-text` is `flex:1; position:relative; will-change:transform;` and the **first** one gets `display:flex; justify-content:flex-end;` so the two words butt up against the center seam and split outward symmetrically.
- `.spotlight-bg-img`: `position:absolute; width:100%; height:100%; overflow:hidden; transform:scale(0); will-change:transform;` — its inner `img` starts at `transform:scale(1.5); will-change:transform;`. (Container scales 0→1 while the image inside counter-scales 1.5→1: a zoom-reveal.)
- `.spotlight-titles-container`: `position:absolute; top:0; left:15vw; width:100%; height:100%; overflow:hidden;` with the **diamond/telescope clip-path** (note the `svh` units inside `polygon()`):
  ```css
  clip-path: polygon(
    50svh 0px,
    0px 50%,
    50svh 100%,
    100% calc(100% + 100svh),
    100% -100svh
  );
  --before-opacity: 0;
  --after-opacity: 0;
  ```
  Its `::before` and `::after` are two white edge lines tracing the diamond's left point: `content:""; position:absolute; width:100svh; height:2.5px; background:#fff; pointer-events:none; transition:opacity 0.3s ease; z-index:10;`. The `::before` sits at `top:0; left:0; transform:rotate(-45deg) translate(-7rem); opacity:var(--before-opacity);` and the `::after` at `bottom:0; left:0; transform:rotate(45deg) translate(-7rem); opacity:var(--after-opacity);`.
- `.spotlight-titles`: `position:relative; left:15%; width:75%; height:100%; display:flex; flex-direction:column; gap:5rem; transform:translateY(100%); z-index:2;` — its `h1`s are `color:#fff; opacity:0.25; transition:opacity 0.3s ease;`.
- `.spotlight-images`: `position:absolute; top:0; right:0; width:50%; min-width:300px; height:100%; z-index:1; pointer-events:none;` — the right-half canvas the thumbnails fly across.
- `.spotlight-img` (each thumbnail wrapper): `position:absolute; width:200px; height:150px; will-change:transform;`
- `.spotlight-header`: `position:absolute; top:50%; left:10%; transform:translateY(-50%); color:#fff; transition:opacity 0.3s ease; z-index:2; opacity:0;` — the "Discover" label.
- `@media (max-width:1000px)`: `h1 { font-size:2rem; }`; `.intro,.outro { padding:2rem; text-align:center; }`; remove the clip-path (`clip-path:none`) and hide the `::before`/`::after` lines (`display:none`); `.spotlight-titles { left:0; }`; `.spotlight-header { display:none; }`.

## JS setup (before the ScrollTrigger)

### Config and data
```js
const config = { gap: 0.08, speed: 0.3, arcRadius: 500 };

const spotlightItems = [
  { name: "Silent Arc",  img: /* image 1 */ },
  { name: "Bloom24",     img: /* image 2 */ },
  { name: "Glass Fade",  img: /* image 3 */ },
  { name: "Echo 9",      img: /* image 4 */ },
  { name: "Velvet Loop", img: /* image 5 */ },
  { name: "Field Two",   img: /* image 6 */ },
  { name: "Pale Thread", img: /* image 7 */ },
  { name: "Stillroom",   img: /* image 8 */ },
  { name: "Ghostline",   img: /* image 9 */ },
  { name: "Mono 73",     img: /* image 10 */ },
];
```
`gap` is the scroll-progress offset between consecutive flying images; `speed` is how much progress each image's flight consumes; `arcRadius` is the horizontal bulge of the bezier arc. These three are interdependent — keep the values exactly.

### DOM generation
For each item, in order:
- Append an `<h1>` with the item name into `.spotlight-titles`. Give **index 0** an inline `style.opacity = "1"` (it is the initially active title; the rest inherit the CSS `0.25`).
- Create `<div class="spotlight-img"><img src="…" alt=""></div>`, append to `.spotlight-images`, and push the wrapper into an `imageElements` array.

Track the highlighted title with `let currentActiveIndex = 0;`.

### Arc geometry (computed once, at load, from the window size)
```js
const containerWidth  = window.innerWidth * 0.3;
const containerHeight = window.innerHeight;
const arcStartX = containerWidth - 220;
const arcStartY = -200;
const arcEndY   = containerHeight + 200;
const arcControlPointX = arcStartX + config.arcRadius; // bulges 500px to the right
const arcControlPointY = containerHeight / 2;
```
Quadratic bezier position for `t ∈ [0,1]` — note that **start X and end X are the same** (`arcStartX`), so the path is a rightward-bulging arc that enters above the viewport and exits below it:
```js
function getBezierPosition(t) {
  const x = (1-t)*(1-t)*arcStartX + 2*(1-t)*t*arcControlPointX + t*t*arcStartX;
  const y = (1-t)*(1-t)*arcStartY + 2*(1-t)*t*arcControlPointY + t*t*arcEndY;
  return { x, y };
}
```
Per-image progress window (staggers the flights):
```js
function getImgProgressState(index, overallProgress) {
  const startTime = index * config.gap;        // 0, 0.08, 0.16, …
  const endTime   = startTime + config.speed;  // each flight spans 0.3 of switchProgress
  if (overallProgress < startTime) return -1;  // not started
  if (overallProgress > endTime)   return 2;   // finished
  return (overallProgress - startTime) / config.speed; // normalized 0..1
}
```
Finally hide all thumbnails at load: `imageElements.forEach((img) => gsap.set(img, { opacity: 0 }));`

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

One single ScrollTrigger, no timeline, no tweens — every frame writes absolute state with `gsap.set` from `onUpdate`:
```js
ScrollTrigger.create({
  trigger: ".spotlight",
  start: "top top",
  end: `+=${window.innerHeight * 10}px`, // 10x viewport of scroll distance
  pin: true,
  pinSpacing: true,
  scrub: 1,
  onUpdate: (self) => { /* phases below, keyed on self.progress */ },
});
```

### Phase 1 — intro split + background zoom-reveal (`progress ≤ 0.2`)
`animationProgress = progress / 0.2` (normalized 0→1).
- `introTextElements[0]` → `x: -animationProgress * (window.innerWidth * 0.6)` (slides left, up to 60vw).
- `introTextElements[1]` → `x: +animationProgress * (window.innerWidth * 0.6)` (slides right). Both get `opacity: 1`.
- `.spotlight-bg-img` → `transform: scale(${animationProgress})` (container grows 0 → 1).
- `.spotlight-bg-img img` → `transform: scale(${1.5 - animationProgress * 0.5})` (inner image counter-shrinks 1.5 → 1, so the photo appears to settle into focus as its frame expands).
- All thumbnail wrappers: `opacity: 0`. Header `style.opacity = "0"`. Set `--before-opacity: "0"` and `--after-opacity: "0"` on `.spotlight-titles-container` (via `gsap.set` with the CSS-variable keys).

### Phase 2 — handoff (`0.2 < progress ≤ 0.25`)
- Lock `.spotlight-bg-img` at `transform: scale(1)` and its `img` at `scale(1)`.
- Both intro texts → `opacity: 0` (they vanish; the CSS transition is not used here, it's a hard set every frame).
- Thumbnails stay `opacity: 0`. Header `style.opacity = "1"` (fades in via its CSS `transition: opacity 0.3s ease`). Set `--before-opacity: "1"`, `--after-opacity: "1"` — the two white diagonal lines of the viewfinder fade in (they also have a 0.3s CSS transition).

### Phase 3 — titles scroll + bezier image flights + active swap (`0.25 < progress ≤ 0.95`)
Keep bg at scale 1, intro texts at opacity 0, header at "1", both line variables at "1". Then:

**Titles column:** `switchProgress = (progress - 0.25) / 0.7` (normalized 0→1 across the phase).
```js
const startPosition  = window.innerHeight;              // column starts fully below the viewport
const targetPosition = -titlesContainer.scrollHeight;    // ends fully above it
const currentY = startPosition - switchProgress * (startPosition - targetPosition);
gsap.set(".spotlight-titles", { transform: `translateY(${currentY}px)` });
```

**Flying thumbnails:** for each image `index`, `imageProgress = getImgProgressState(index, switchProgress)`. If it is `< 0` or `> 1` set the wrapper to `opacity: 0`; otherwise:
```js
const pos = getBezierPosition(imageProgress);
gsap.set(img, { x: pos.x - 100, y: pos.y - 75, opacity: 1 }); // -100/-75 centers the 200x150 card
```
The result: thumbnails appear one after another (staggered by 0.08 of switchProgress), each sweeping down the right half of the screen along the arc — entering from above (y = −200), bulging up to 500px to the right at mid-height, and exiting below (y = viewportHeight + 200) — then blinking out.

**Active title detection + background swap (every update in this phase):** measure each title with `getBoundingClientRect()`; the title whose vertical center is closest to `window.innerHeight / 2` is the active one. When the closest index changes:
- previous active `<h1>` → `style.opacity = "0.25"`, new one → `style.opacity = "1"` (the 0.3s CSS transition makes it a soft crossfade);
- swap the full-bleed background: `document.querySelector(".spotlight-bg-img img").src = spotlightItems[closestIndex].img;`
- update `currentActiveIndex`.

### Phase 4 — exit (`progress > 0.95`)
Header `style.opacity = "0"`, `--before-opacity: "0"`, `--after-opacity: "0"` — the viewfinder chrome fades out just before the pin releases and the outro section scrolls in.

Note there is **no ease/duration anywhere** — all motion is a direct linear mapping of scroll progress through `gsap.set`, softened only by `scrub: 1` (one-second catch-up) and Lenis' smooth scrolling. No SplitText, no CustomEase, no Three.js.

## Assets / images
**10 photographs**, one per gallery item. Each image plays two roles: (a) the **full-bleed background** (`object-fit:cover`, fills the viewport when its title is active) and (b) a **200×150 px (4:3 landscape) flying thumbnail** (also `object-fit:cover`). Landscape-leaning images work best. Art direction: a cohesive series of **surreal, muted-tone editorial/art-photography frames** — dreamlike still lifes, figures and landscapes in desaturated, filmic colors that read well both full-screen and tiny. The first image is also hardcoded as the initial `src` of `.spotlight-bg-img img`. Name them sequentially (`img_1 … img_10`).

## Behavior notes
- The intro and outro sections are plain static screens; only `.spotlight` pins. Total scroll distance through the pinned section is **10× the viewport height**, so the sequence feels slow and cinematic.
- Scrolling is fully reversible — every phase is a pure function of `self.progress`, so scrubbing backwards replays everything in reverse (including the background swaps and title highlights).
- Arc geometry and the ScrollTrigger `end` are computed from the window size **once at load**; there is no resize handler in the original.
- Under 1000px wide the clip-path viewfinder, the diagonal lines and the "Discover" header are removed via CSS; the titles column and flying images still animate.
- No reduced-motion handling in the original.
