# Orchestra 3D Scroll Animation

## Goal

Build a pinned full-screen hero where six image-mapped CSS 3D cubes fly in from extremely deep 3D space (`translateZ(-30000px)`) and assemble into a symmetric spread as the user scrolls, while a geometric block logo blurs away, a large intro headline scales up and fades out, and a second headline de-blurs into view. Everything is driven by a single scrubbed, pinned ScrollTrigger whose `onUpdate` manually interpolates every value from scroll progress (no tweens).

## Tech

Vanilla HTML/CSS/JS with ES module imports. Use `gsap` (npm) plus the GSAP plugin `ScrollTrigger`, and `lenis` (npm) for smooth scrolling. Register ScrollTrigger with `gsap.registerPlugin(ScrollTrigger)`. No other libraries.

Wire Lenis to GSAP exactly like this:

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

Run all JS inside `DOMContentLoaded`.

## Layout / HTML

Two full-viewport `<section>` elements:

```
section.sticky
  div.logo
    div.col > div.block.block-1 + div.block.block-2
    div.col > div.block.block-3 + div.block.block-4
    div.col > div.block.block-5 + div.block.block-6
  div.cubes
    div.cube.cube-1 > div.front + div.back + div.right + div.left + div.top + div.bottom
    div.cube.cube-2 > (same 6 face divs)
    div.cube.cube-3 > (same 6 face divs)
    div.cube.cube-4 > (same 6 face divs)
    div.cube.cube-5 > (same 6 face divs)
    div.cube.cube-6 > (same 6 face divs)
  div.header-1
    h1 "The first media company crafted for the digital first generation."
  div.header-2
    h2 "Where innovation meets precision."
    p  "Symphonia unites visionary thinkers, creative architects, and analytical experts, collaborating seamlessly to transform challenges into opportunities. Together, we deliver tailored solutions that drive impact and inspire growth."

section.about
  h2 "Your next section goes here"
```

The cube faces start empty; JS fills each one with an `<img>` (see GSAP effect section).

## Styling

- Global reset: `* { margin: 0; padding: 0; box-sizing: border-box; }`.
- `html, body { width: 100vw; height: 600vh; }`. Font: a neutral grotesque sans-serif (a custom grotesque like "Notch Grotesk" if available; system sans fallback is fine).
- `img { width: 100%; height: 100%; object-fit: cover; }`.
- Every `section`: `position: relative; width: 100vw; height: 100vh; overflow: hidden;`.
- `.sticky`: background `#331707` (very dark brown), text `#ffe9d9` (cream).
- `.about`: flex-centered text, background `#cdb9ab` (warm greige), text `#331707`.

**Logo** (an abstract mark built from 6 cream squares):

- `.logo`: `position: absolute; top: 25%; left: 50%; transform: translate(-50%, -50%); display: flex; gap: 24px; z-index: 2;`.
- `.col`: `display: flex; flex-direction: column; justify-content: flex-end;`. The 2nd column gets `gap: 26px`.
- `.block`: `35px × 35px`, background `#ffe9d9`.
- `.block-1`: `transform: rotate(42deg); transform-origin: bottom right;`. `.block-5`: `transform: rotate(-42deg); transform-origin: bottom left;`.

**Cubes container and cube geometry**:

- `.cubes`: `position: absolute; top: 0; left: 0; width: 100vw; height: 100vh; transform-style: preserve-3d; perspective: 10000px;`.
- `.cube`: `position: absolute; width: 150px; height: 150px; transform-style: preserve-3d;`.
- `.cube > div` (each face): `position: absolute; width: 150px; height: 150px; transform-style: preserve-3d; backface-visibility: visible;`.
- Face transforms: `.front { transform: translateZ(75px); }`, `.back { transform: translateZ(-75px) rotateY(180deg); }`, `.right { transform: translateX(75px) rotateY(90deg); }`, `.left { transform: translateX(-75px) rotateY(-90deg); }`, `.top { transform: translateY(-75px) rotateX(90deg); }`, `.bottom { transform: translateY(75px) rotateX(-90deg); }`.
- Static CSS starting state per cube — position with `top`/`left` (percentages) and `transform: translate3d(-50%, -50%, -30000px) rotateX(...) rotateY(...) rotateZ(...)` using the **initial** values from the table in the GSAP section below (all cubes start at z `-30000px`, above the viewport with negative `top`).

**Headers** (both absolutely centered in the sticky section, `text-align: center`, color `#ffe9d9`):

- `.header-1`: `width: 60%; top: 50%; left: 50%; transform: translate(-50%, -50%) scale(1); transform-origin: center center;`. Its `h1`: `font-weight: 400; font-size: 4rem; line-height: 1;`.
- `.header-2`: `width: 30%; top: 50%; left: 50%; transform: translate(-50%, -50%) scale(0.75); transform-origin: center center; opacity: 0; filter: blur(10px);`. Its `h2` has `margin-bottom: 0.5rem`; its `p`: `font-size: 1.25rem; font-weight: lighter;`.
- Include the standard Lenis recommended CSS (`.lenis.lenis-smooth [data-lenis-prevent] { overscroll-behavior: contain; }`, `.lenis.lenis-stopped { overflow: clip; }`, `.lenis.lenis-smooth iframe { pointer-events: none; }`).

## GSAP effect (exhaustive)

### Setup

1. Query `.sticky`, `.logo`, `.cubes`, `.header-1`, `.header-2`.
2. `const stickyHeight = window.innerHeight * 4;`
3. **Populate cube faces**: select all `.cube > div`, and for each face create an `<img>` and append it. Assign the images sequentially face by face in DOM order (cube-1 front→back→right→left→top→bottom, then cube-2, etc. — **36 faces total**). The demo ships **33** images, so wrap the index with modulo — `img${((i - 1) % 33) + 1}.jpeg` (1-based counter) — meaning the last 3 faces (cube-6's top/bottom-adjacent faces) reuse images 1–3. If your image set differs, cycle the same way so every face gets one.
4. Define a linear interpolation helper: `interpolate(start, end, progress) => start + (end - start) * progress`.

### ScrollTrigger

Create **one** ScrollTrigger (no timeline, no tweens — everything happens in `onUpdate` by writing inline styles):

```
ScrollTrigger.create({
  trigger: stickySection,
  start: "top top",
  end: `+=${stickyHeight}px`,   // 4 × viewport height
  scrub: 1,
  pin: true,
  pinSpacing: true,
  onUpdate: (self) => { ... }
})
```

### onUpdate logic (all driven by `self.progress`, 0 → 1)

All sub-progress values are clamped to [0, 1].

**Logo** (blurs out and disappears almost immediately):

- Blur: `initialProgress = min(progress * 20, 1)`; set `logo.style.filter = blur(lerp(0, 20, initialProgress)px)` — i.e. full 20px blur by progress 0.05.
- Opacity: for `progress >= 0.02`, `logoOpacityProgress = min((progress - 0.02) * 100, 1)`, else 0; set `logo.style.opacity = 1 - logoOpacityProgress` — fades 1→0 between progress 0.02 and 0.03.

**Cubes container opacity** (fades in right at the start):

- For `progress >= 0.01`, `cubesOpacityProgress = min((progress - 0.01) * 100, 1)`, else 0; set `cubesContainer.style.opacity = cubesOpacityProgress` — 0→1 between progress 0.01 and 0.02.

**Header 1** (scales up, blurs and fades out over the first 40% of scroll):

- `header1Progress = min(progress * 2.5, 1)`.
- `transform: translate(-50%, -50%) scale(lerp(1, 1.5, header1Progress))`.
- `filter: blur(lerp(0, 20, header1Progress)px)`.
- `opacity: 1 - header1Progress`.

**Header 2** (de-blurs and scales in between 40% and 50% of scroll):

- `header2Progress = clamp((progress - 0.4) * 10, 0, 1)`.
- `transform: translate(-50%, -50%) scale(lerp(0.75, 1, header2Progress))`.
- `filter: blur(lerp(10, 0, header2Progress)px)`.
- `opacity: header2Progress`.

**Cubes** (two phases):

- `firstPhaseProgress = min(progress * 2, 1)` — the fly-in/assembly runs over the first half of the scroll.
- `secondPhaseProgress = progress >= 0.5 ? (progress - 0.5) * 2 : 0` — extra flips run over the second half.

For each cube, linearly interpolate every property from `initial` to `final` using `firstPhaseProgress`, then write inline styles:

- `cube.style.top = "{top}%"`, `cube.style.left = "{left}%"`
- `cube.style.transform = "translate3d(-50%, -50%, {z}px) rotateX({rotateX}deg) rotateY({rotateY + additionalRotation}deg) rotateZ({rotateZ}deg)"`

Per-cube data (keep these exact values in a lookup object keyed by class name):

| Cube | initial top | initial left | initial rotateX | initial rotateY | initial rotateZ | initial z | final top | final left | final rotateX | final rotateY | final rotateZ | final z |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| cube-1 | -55 | 37.5 | 360 | -360 | -48 | -30000 | 50 | 15 | 0 | 3 | 0 | 0 |
| cube-2 | -35 | 32.5 | -360 | 360 | 90 | -30000 | 75 | 25 | 1 | 2 | 0 | 0 |
| cube-3 | -65 | 50 | -360 | -360 | -180 | -30000 | 25 | 25 | -1 | 2 | 0 | 0 |
| cube-4 | -35 | 50 | -360 | -360 | -180 | -30000 | 75 | 75 | 1 | -2 | 0 | 0 |
| cube-5 | -55 | 62.5 | 360 | 360 | -135 | -30000 | 25 | 75 | -1 | -2 | 0 | 0 |
| cube-6 | -35 | 67.5 | -180 | -360 | -180 | -30000 | 50 | 85 | 0 | -3 | 0 | 0 |

**Second-phase extra rotation** (only two cubes): during the second half, `cube-2` gets `additionalRotation = lerp(0, 180, secondPhaseProgress)` and `cube-4` gets `additionalRotation = lerp(0, -180, secondPhaseProgress)`, added to their rotateY. All other cubes get 0.

Net result: the six cubes tumble in from deep space (multiple full 360° rotations unwinding to nearly flat, subtle ±1–3° final tilts) and settle into a 2×3 symmetric grid around the centered second headline (left column at 25%, right at 75%, outer singles at 15%/85%, rows at 25%/50%/75%), then two of them slowly flip 180° to reveal their back-face image while the user finishes scrolling.

## Assets / images

**33 square images** (effectively 1:1, since each fills a 150×150px face with `object-fit: cover`) — a warm, gallery-grade editorial/lifestyle mix: fashion portraits, minimalist architecture interiors, product still-lifes (ceramics, perfume, lamps), landscapes and abstract graphic compositions. No brand marks. They map one per cube face in DOM order across the 36 faces, so the final 3 faces reuse the first 3 images (see setup step 3). Any count works: the code cycles through the available list.

## Behavior notes

- The whole effect lives in the single pinned ScrollTrigger; scrolling back up plays everything in reverse (scrub).
- `scrub: 1` plus Lenis gives the animation a soft, damped feel.
- The `.about` section after the pinned hero is plain static content confirming the pin releases correctly.
- No resize handling, no reduced-motion branch in the original; desktop-oriented but functional on mobile.
