# 3D Product Scroll Showcase — pinned scrub section with rotating GLTF model

## Goal

Build a full-page scroll experience for a fictional fitness shaker brand called "GRND". The star of the show is a pinned, scrub-driven section where a Three.js GLTF shaker-bottle model spins on its Y axis in sync with scroll progress while, mapped to the same progress value, two giant headlines slide horizontally across the screen, a dark circular clip-path mask expands to swallow the background, and two feature tooltips reveal with masked, staggered SplitText line animations.

## Tech

Vanilla HTML/CSS/JS with ES module imports. Use `gsap` (npm) plus the GSAP plugins `ScrollTrigger` and `SplitText`, `lenis` for smooth scrolling, and `three` (npm) with `GLTFLoader` from `three/examples/jsm/loaders/GLTFLoader.js` for the 3D model. Icons are Ionicons web components loaded from the unpkg CDN (`https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js` as a module script plus the `nomodule` fallback).

All JS runs inside a `DOMContentLoaded` listener. Register `ScrollTrigger` and `SplitText` with `gsap.registerPlugin`.

## Layout / HTML

Three stacked full-viewport sections:

1. `<section class="intro">` — a single `<h1>` with the copy "GRND doesn't shake. It performs."
2. `<section class="product-overview">` — the pinned showcase, containing in this order:
   - `<div class="header-1"><h1>Every Rep Starts With</h1></div>`
   - `<div class="header-2"><h1>GRND Shaker</h1></div>`
   - `<div class="circular-mask"></div>`
   - `<div class="tooltips">` with exactly two `<div class="tooltip">` blocks. Each tooltip contains, in order: `<div class="icon"><ion-icon name="..."></ion-icon></div>`, `<div class="divider"></div>`, `<div class="title"><h2>...</h2></div>`, `<div class="description"><p>...</p></div>`.
     - Tooltip 1: icon `flash`, title "Built to last", description "Designed to match your pace, GRND runs all week on a single charge. No interruptions, no slowing down."
     - Tooltip 2: icon `bluetooth`, title "Stay synced", description "With app integration, GRND helps you stay consistent. Monitor intake, set goals, and make every sip count."
   - `<div class="model-container"></div>` — empty; the WebGL canvas is appended here.
3. `<section class="outro">` — a single `<h1>` with the copy "Don't Just Train — GRND".

## Styling

- Font: `"PP Neue Montreal", sans-serif` (import from `https://fonts.cdnfonts.com/css/pp-neue-montreal`). Global reset: `* { margin: 0; padding: 0; box-sizing: border-box; }`.
- Type scale: `h1` 4rem / weight 500 / line-height 1. `h2` 3rem / weight 500 / line-height 1.125 / letter-spacing -0.03rem. `p` 1.1rem / weight 500 / line-height 1.5.
- Every `section` is `position: relative; width: 100vw; height: 100svh; overflow: hidden;` with background `#e0dfdf` and text `#0d0d0d`.
- `.intro` and `.outro` invert the palette (background `#0d0d0d`, text `#e0dfdf`), flex-centered, `padding: 2rem`.
- `.model-container`: absolutely centered (`top/left 50%`, `translate(-50%, -50%)`), `width/height 100%`, `z-index: 100`.
- `.header-1`: `position: relative; width: 200vw; height: 100svh;` color `#0d0d0d`, initial `transform: translateX(0%)`.
- `.header-2`: `position: fixed; top: 0; left: 0; width: 150vw; height: 100svh;` color `#e0dfdf`, initial `transform: translateX(100%)`, `z-index: 2`.
- Both headers are flex containers (`align-items: center; padding: 0 2rem`) and their `h1` is `width: 100%; font-size: 15vw; line-height: 1.25; letter-spacing: -0.02em` — a single huge line that overflows the viewport horizontally.
- `.circular-mask`: absolute, fills the section, background `#0d0d0d`, initial `clip-path: circle(0% at 50% 50%)`.
- `.tooltips`: absolutely centered, `width: 75%; height: 75%`, `display: flex; gap: 15rem`.
- `.tooltip`: `flex: 1`, column flex with `gap: 0.5rem`, color `#e0dfdf`. The second tooltip is pushed to the bottom-right of its column (`justify-content: flex-end; align-items: flex-end`).
- `.tooltip .divider`: `width: 100%; height: 1px; background: #5f5f5f; margin: 0.5rem 0;` initial `transform: scaleX(0%)`. Transform-origin: `right center` on tooltip 1, `left center` on tooltip 2 (so they grow toward each other from the middle).
- `.tooltip .icon`: `font-size: 2.5rem; overflow: hidden`. `.tooltip .description`: color `#5f5f5f`. On tooltip 2, `.icon` and `.title` are `width: 70%`; every `.description` is also `width: 70%`.
- Text-mask plumbing (critical for the reveal effect):
  - `.header-1 h1 .char`, `.tooltip .title .line`, `.tooltip .description .line` → `display: inline-block; overflow: hidden;`
  - `.header-1 h1 .char > span`, `.tooltip .icon ion-icon`, `.tooltip .title .line > span`, `.tooltip .description .line > span` → `position: relative; display: block; transform: translateY(100%); will-change: transform;` (everything starts hidden below its overflow mask).
- `@media (max-width: 1000px)`: `h1` 2rem and centered; `h2` and `.tooltip .icon` 1.5rem; `.tooltips` becomes a full-width centered column with `gap: 2rem`; each `.tooltip` is `width: 85%` and tooltip 2 resets to `flex-start` alignment; dividers become `width: 70%` and tooltip 2's divider origin flips to `right center`; tooltip 2's icon/title go back to `width: 100%`.

## GSAP effect (exhaustive)

### Smooth scroll bridge

Instantiate `new Lenis()`, wire `lenis.on("scroll", ScrollTrigger.update)`, drive it with `gsap.ticker.add((time) => lenis.raf(time * 1000))`, and call `gsap.ticker.lagSmoothing(0)`.

### SplitText setup

- Split `.header-1 h1` with `type: "chars", charsClass: "char"`.
- Split `.tooltip .title h2` and `.tooltip .description p` with `type: "lines", linesClass: "line"`.
- After splitting, manually wrap the innerHTML of every char and every line in a `<span>` (e.g. `char.innerHTML = `<span>${char.innerHTML}</span>``). The outer char/line acts as an `overflow: hidden` mask and the inner span is the element that animates (CSS starts it at `translateY(100%)`).

### Shared tween options

`const animOptions = { duration: 1, ease: "power3.out", stagger: 0.025 };` — reused by the divider and tooltip tweens below.

### ScrollTrigger #1 — header-1 char reveal on approach

`ScrollTrigger.create` with `trigger: ".product-overview"`, `start: "75% bottom"` (no scrub):

- `onEnter`: `gsap.to(".header-1 h1 .char > span", { y: "0%", duration: 1, ease: "power3.out", stagger: 0.025 })` — chars rise out of their masks left to right.
- `onLeaveBack`: same tween back to `y: "100%"`.

### ScrollTrigger #2 — the pinned scrub timeline (imperative, via onUpdate)

`ScrollTrigger.create` with `trigger: ".product-overview"`, `start: "top top"`, `end: "+=" + window.innerHeight * 10 + "px"` (the section stays pinned for 10 viewport heights), `pin: true`, `pinSpacing: true`, `scrub: 1`.

Everything below happens inside `onUpdate: ({ progress }) => { ... }` — each range is mapped by hand from the 0→1 progress and applied with `gsap.to` calls (the header/mask tweens use GSAP's default duration/ease, which gives them a soft glide on top of the scrub):

1. **Header 1 slides out left** — progress 0.05→0.35: `gsap.to(".header-1", { xPercent })` where `xPercent` is `0` below 0.05, `-100` above 0.35, otherwise `-100 * ((progress - 0.05) / 0.3)`.
2. **Circular mask expands** — progress 0.20→0.30: compute `maskSize` = `0` below 0.2, `100` above 0.3, otherwise `100 * ((progress - 0.2) / 0.1)`, then `gsap.to(".circular-mask", { clipPath: \`circle(${maskSize}% at 50% 50%)\` })`. The dark circle grows from the center until it covers the whole section, flipping the scene from light to dark.
3. **Header 2 sweeps across** — progress 0.15→0.50: `gsap.to(".header-2", { xPercent })` where `xPercent` is `100` below 0.15, `-200` above 0.5, otherwise `100 - 300 * ((progress - 0.15) / 0.35)` (it enters from the right, crosses the screen, and exits fully left — a 300% travel).
4. **Dividers grow** — progress 0.45→0.65: `scaleX` = `0` below 0.45, `100` above 0.65, otherwise `100 * ((progress - 0.45) / 0.2)`; apply with `gsap.to(".tooltip .divider", { scaleX: \`${scaleX}%\`, ...animOptions })`.
5. **Tooltip content reveals (threshold toggles, not ramps)** — two groups:
   - Group 1 at `progress >= 0.65`: `".tooltip:nth-child(1) .icon ion-icon"`, `".tooltip:nth-child(1) .title .line > span"`, `".tooltip:nth-child(1) .description .line > span"`.
   - Group 2 at `progress >= 0.85`: same three selectors for `.tooltip:nth-child(2)`.
   - For each group: `gsap.to(elements, { y: progress >= threshold ? "0%" : "125%", ...animOptions })` — so each group snaps its target and eases in/out with the 0.025 stagger, and scrolling back re-hides it.
6. **Model rotation** — for `progress >= 0.05`: `rotationProgress = (progress - 0.05) / 0.95`, `targetRotation = Math.PI * 3 * 4 * rotationProgress` (12π rad ≈ 6 full turns across the pin). Keep a `currentRotation` accumulator and apply only the delta: if `|targetRotation - currentRotation| > 0.001`, call `model.rotateOnAxis(new THREE.Vector3(0, 1, 0), diff)` and update the accumulator. This rotates around the model's local Y axis so the tilted bottle spins about its own long axis.

### Three.js scene

- `THREE.Scene`, `PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 1000)`.
- `WebGLRenderer({ antialias: true, alpha: true })`; `setClearColor(0x000000, 0)` (transparent — the page background shows through); size = window; `setPixelRatio(Math.min(devicePixelRatio, 2))`; `shadowMap.enabled = true` with `PCFSoftShadowMap`; `outputEncoding = THREE.LinearEncoding`; `toneMapping = THREE.NoToneMapping`, exposure 1.0. Append `renderer.domElement` to `.model-container`.
- Lights: `AmbientLight(0xffffff, 0.7)`; main `DirectionalLight(0xffffff, 1.0)` at `(1, 2, 3)` with `castShadow`, `shadow.bias = -0.001`, 1024×1024 shadow map; fill `DirectionalLight(0xffffff, 0.5)` at `(-2, 0, -2)`.
- Load the `.glb` with `GLTFLoader`. On load, traverse the scene and on every mesh material set `metalness: 0.05, roughness: 0.9` (matte look). Measure the model with `new THREE.Box3().setFromObject(model)` and store its size vector.
- `setupModel()` (called on load and on every resize; guard until model + size exist):
  - `isMobile = window.innerWidth < 1000`.
  - Position: x = `center.x + size.x * 1` on mobile, `-center.x - size.x * 0.4` on desktop (pushes the bottle left of center so it sits under the tooltip gap); y = `-center.y + size.y * 0.085`; z = `-center.z`.
  - `model.rotation.z = -25°` (in radians) on desktop, `0` on mobile — a jaunty tilt.
  - Camera at `(0, 0, maxDimension * d)` where `d` = 2 on mobile, 1.25 on desktop, looking at the origin.
- Plain `requestAnimationFrame` loop calling `renderer.render(scene, camera)`.
- `resize` listener: update camera aspect + projection matrix, renderer size, and re-run `setupModel()`.

## Assets / images

- **1 × GLTF binary model (`.glb`)**: a fitness shaker bottle (cylindrical drink bottle with a lid), roughly upright, real-world scale irrelevant since the camera frames it from its bounding box. Load it from a local path (e.g. `./shaker.glb`). No textures required beyond whatever the model ships with — materials get forced matte at runtime.
- No raster images. Two Ionicons glyphs (`flash`, `bluetooth`) render via the `<ion-icon>` web component.

## Behavior notes

- The whole page scroll is smoothed by Lenis; the showcase section is pinned for 10 viewport heights, so plan for a tall scroll.
- Scrolling backwards fully reverses every stage (headers slide back, mask shrinks, tooltips re-hide via the `125%` branch, the model spins back because rotation is delta-based).
- The scrub pipeline is imperative (a single `onUpdate` doing range-mapping) rather than a declarative timeline — reproduce it that way to get the same layered easing feel.
- Desktop-first; below 1000px the tooltips stack vertically, the model shifts right of center and loses its tilt, and the camera pulls back.
- The header text intentionally overflows the viewport (200vw / 150vw containers with 15vw type) — do not "fix" that; the horizontal slide depends on it.
