# 3D Parallax Reveal Footer

## Goal
Build a full-page scroll layout where the last screen is a **dark footer that is pinned behind the page and "un-parallaxes" into place as you scroll to it**. The footer's inner content starts pushed up (`translateY(-35%)`) and slides down to `0%` while a semi-transparent **Three.js GLB model living inside the footer dollies toward the camera (z: -1 → 0) and un-tilts (rotation.x: 0.5 → 0)** — both driven by the same scrubbed ScrollTrigger. On top of that, a requestAnimationFrame loop adds **mouse parallax**, lerping the model's rotation toward pointer-driven targets. Smooth scroll via Lenis.

## Tech
Vanilla HTML/CSS/JS with ES module imports. Use `gsap` (npm) plus the GSAP plugin `ScrollTrigger`, `lenis` for smooth scroll, and `three` for the WebGL model (import `three` and `GLTFLoader` from `three/addons/loaders/GLTFLoader.js`). No framework, no bund-specific APIs. Register the plugin with `gsap.registerPlugin(ScrollTrigger)`.

## Layout / HTML
Body is a vertical stack of three full-height spacer sections followed by the footer:

```
<section class="one"><h1>Section 1</h1></section>
<section class="two"><h1>Section 2</h1></section>
<section class="three"><h1>Section 3</h1></section>

<footer>
  <div class="footer-container">
    <div id="footer-canvas"></div>          <!-- Three.js renderer mounts here -->

    <div class="footer-content">
      <div class="footer-row">
        <div class="footer-col">
          <h2>Restoring meaning to the things we build</h2>
        </div>
        <div class="footer-col">
          <div class="footer-sub-col">
            <h3>Work resumes</h3>
            <h3>2026</h3>
          </div>
          <div class="footer-sub-col">
            <a href="#">Write to me</a>
            <a href="#">Professional orbit</a>
            <a href="#">Loose thoughts</a>
            <a href="#">Long form</a>
          </div>
        </div>
      </div>

      <div class="footer-row">
        <p>Experiment 518</p>
        <p>Built by Motionprompts</p>
      </div>
    </div>
  </div>
</footer>

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

Key classes the JS/CSS depend on: `.footer-container` (the element that gets the `translateY` parallax), `#footer-canvas` (Three.js mount), `.footer-content`, `.footer-row`, `.footer-col`, `.footer-sub-col`. The scroll trigger targets the raw `footer` element.

## Styling

**Fonts** — load from Google Fonts: `DM Sans` (full optical/weight range) and `Stack Sans Notch` (weights 200..700). `body` uses `"Stack Sans Notch", sans-serif`; `p` uses `"DM Sans", sans-serif`.

**Color tokens**
- `--base-100: #d6eed9` (pale mint) — section one bg, text black
- `--base-200: #b30078` (magenta) — section two bg, text white
- section three bg `#ffa630` (orange), text black
- `--base-400: #171717` (near-black) — footer bg, text white

**Type scale** (all headings `font-weight: lighter; line-height: 1.25`)
- `h1`: `clamp(4rem, 5vw, 6rem)`
- `h2`: `clamp(3rem, 4vw, 5rem)`
- `h3` and `a`: `clamp(1.25rem, 1.75vw, 2.5rem)`, `font-weight: lighter`
- `a`: `color: #fff`, no underline
- `p`: DM Sans, `0.9rem`, weight 400

**Reset / global**
- `* { margin:0; padding:0; box-sizing:border-box }`
- Hide scrollbar: `::-webkit-scrollbar { display:none }`

**Sections** — `position: relative; width: 100%; height: 100svh; padding: 2rem; display:flex; justify-content:center; align-items:center; z-index: 1`. The `z-index:1` and opaque backgrounds are what make the three sections **cover the footer** while scrolling; the footer is revealed only as those sections scroll away.

**Footer** — `position: relative; width: 100%; height: 75svh; background: #171717; color: #fff; overflow: hidden` (overflow hidden clips the parallaxing content and the oversized canvas).

**.footer-container** — `position: relative; width:100%; height:100%; padding: 2rem; display:flex; flex-direction:column; justify-content:space-between; transform: translateY(-35%); will-change: transform`. This is the initial parallax offset the scroll animation drives back to 0.

**#footer-canvas** — `position:absolute; inset: 0 auto auto 0; top:0; left:0; width:100%; height:100%; pointer-events:none; opacity: 0.5; z-index: -1` (sits behind the text, dimmed).

**.footer-content** — `position:relative; width:100%; height:100%; display:flex; flex-direction:column; justify-content:space-between; z-index: 1`.

**.footer-row** — `display:flex; justify-content:space-between`.
- First row, col 1: `flex: 3`. First row, col 2: `flex: 2; display:flex; gap: 2rem`.
- `.footer-sub-col`: `flex:1; display:flex; flex-direction:column; gap: 0.5rem`.
- `.footer-row h2 { width: 75% }`.

**Responsive** `@media (max-width:1000px)`: first `.footer-row` becomes `flex-direction: column; gap: 4rem`; `.footer-row h2` becomes `width: 100%`.

## GSAP + Three.js effect (be exact)

Wrap everything in `DOMContentLoaded`.

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

### 2. Normalized mouse tracking
Keep a `mouse = { x: 0, y: 0 }` object. On `mousemove`:
- `mouse.x = (e.clientX / window.innerWidth) * 2 - 1`  → range −1..1
- `mouse.y = -(e.clientY / window.innerHeight) * 2 + 1` → range −1..1 (Y inverted for WebGL space)

### 3. Three.js scene
- Mount into `#footer-canvas` (call it `container`).
- `scene = new THREE.Scene()`.
- `camera = new THREE.PerspectiveCamera(50, container.offsetWidth / container.offsetHeight, 0.1, 1000)`; `camera.position.set(0, 0, 0.75)`.
- `renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true })`; `renderer.setSize(container.offsetWidth, container.offsetHeight)`; `renderer.setPixelRatio(window.devicePixelRatio)`; append `renderer.domElement` to the container.
- One light: `const directionalLight = new THREE.DirectionalLight(0xffffff, 5); directionalLight.position.set(1, 1, 0); scene.add(directionalLight)`.

### 4. Load and normalize the GLB
Two module-scope base vars: `let modelBaseRotationX = 0.5;` and `let modelBaseZ = -1;`. Load with `GLTFLoader` from `/c/3d-parallax-footer/model.glb`. In the callback:
- `model = gltf.scene`.
- Compute bounds: `const box = new THREE.Box3().setFromObject(model); const center = box.getCenter(new THREE.Vector3()); const size = box.getSize(new THREE.Vector3());`
- Recenter and place: `model.position.sub(center); model.position.y = 0; model.position.z = -1; model.rotation.x = 0.5;`
- Fit to unit size: `const maxDim = Math.max(size.x, size.y, size.z); model.scale.setScalar(1 / maxDim);`
- `scene.add(model)`.

### 5. Scrubbed ScrollTrigger (the reveal) — the star
```
ScrollTrigger.create({
  trigger: "footer",
  start: "top bottom",     // begins when footer's top hits viewport bottom
  end: "bottom bottom",    // ends when footer's bottom hits viewport bottom
  scrub: true,
  onUpdate: (self) => {
    const progress = self.progress;                 // 0 → 1
    const yValue = -35 * (1 - progress);            // -35% → 0%
    gsap.set(footerContainer, { y: `${yValue}%` }); // un-parallax the content
    modelBaseZ = -1 * (1 - progress);              // model dolly base: -1 → 0
    modelBaseRotationX = 0.5 * (1 - progress);     // model tilt base: 0.5 → 0
  },
});
```
`footerContainer` is `document.querySelector(".footer-container")`. Note the trigger does **not** animate the model directly — it only rewrites the two base targets, and the rAF loop below eases the model toward them. There is no pin and no timeline; it is a single scrub tied to progress with `gsap.set`.

### 6. requestAnimationFrame loop (mouse parallax + eased follow)
```
function animate() {
  requestAnimationFrame(animate);
  if (model) {
    const targetRotationY = mouse.x * 0.3;                        // yaw from pointer X
    const targetRotationX = -mouse.y * 0.2 + modelBaseRotationX;  // pitch from pointer Y + scroll base
    model.rotation.y += (targetRotationY - model.rotation.y) * 0.05;
    model.rotation.x += (targetRotationX - model.rotation.x) * 0.05;
    model.position.z += (modelBaseZ - model.position.z) * 0.05;   // eased dolly toward scroll base
  }
  renderer.render(scene, camera);
}
animate();
```
The lerp factor is **0.05** on all three (rotation.y, rotation.x, position.z), giving a soft, laggy follow. Pointer amplitude: **±0.3 rad** yaw, **±0.2 rad** pitch. The scroll base values (`modelBaseZ`, `modelBaseRotationX`) are constantly folded in, so scroll and mouse compose in the same loop.

### 7. Resize
On `window` `resize`: `camera.aspect = container.offsetWidth / container.offsetHeight; camera.updateProjectionMatrix(); renderer.setSize(container.offsetWidth, container.offsetHeight)`.

## Assets
- **One GLB 3D model** (`/c/3d-parallax-footer/model.glb`): a single small decorative/sculptural object. It is auto-centered, auto-scaled to a unit bounding box, lit by one white directional light, rendered on a transparent canvas at **50% opacity behind the footer text**. Treat it as an ambient background element, not a hero object — any compact glTF/GLB mesh works. No raster images are used anywhere in the component.

## Behavior notes
- The three opaque, full-height sections physically cover the footer (via `z-index` + solid backgrounds); the footer only appears as they scroll off, which is what sells the "reveal from behind" illusion together with the `translateY(-35%) → 0` parallax.
- The model motion is intentionally decoupled from the scrub: the ScrollTrigger sets *targets*, the rAF loop *eases* toward them, so the model keeps drifting/settling even after scroll stops.
- WebGL canvas uses `pointer-events: none`; mouse parallax reads from `window` mousemove, so it works even though the canvas ignores pointer events.
- No reduced-motion guard in the original; the loop runs continuously. Everything degrades gracefully if the GLB fails to load (the `if (model)` guard just skips the model updates and the footer still parallaxes).
