Interactive 3D Product (GLTF) Rotate + Orbit
A GLB perfume flask idles on a slow turntable under a key/fill/rim studio rig with a soft contact shadow; drag to orbit with damping, pick a finish to tween the PBR colour, and labels track points on the model.
- WebGL & 3D
- threejs
- gsap
- advanced
- heavy · WebGL
- mobile: full
- reduced-motion ✓
AI prompt
Recreate this effect with any AI tool
Paste into Claude, Cursor, v0, Lovable or Bolt. 1572 words of exact values — rewritten live as you tune the Playground.
### EFFECT: Interactive 3D Product (GLTF) Rotate + Orbit
**What it does (visual):** A full-viewport dark stage with a large thin serif headline top-left ("Maison Nocturne"), a short muted paragraph, and three pill-shaped colour swatches bottom-left. In the centre, a glossy perfume flask (a GLB) rises 0.4 units from below while scaling 0.9 → 1 and turning ~63° over 1.4–1.6s, and a soft contact shadow (opacity 0.3) fades in beneath it on an otherwise invisible floor. The flask then idles on a slow turntable (`autoRotateSpeed` 0.6; one revolution ≈ 100s at the default). The user can drag to orbit (the camera coasts to a stop with damping 0.06) and scroll/pinch to zoom between fixed limits; it is impossible to look from directly above or from under the floor. Two small accent-coloured (#d4ff3a) dots with hairline leader lines and mono labels ("Hand-turned brass cap", "Recycled glass, 120 ml") stick to the cap and to the side of the bottle that currently faces the camera, following it as it rotates. Clicking a swatch cross-fades the body's PBR colour to the new finish over 0.6s while the brass cap stays metallic. The whole frame is ACES tone-mapped at exposure 1.05. Reflections come from a studio-like environment (bright top panel, warm key strip, cool fill, a lime rim accent) generated at runtime, not from an HDR file.
**Tech stack:**
- Three.js r160+ (`three`, `three/addons/controls/OrbitControls.js`, `three/addons/loaders/GLTFLoader.js`) via importmap or `npm i three`.
- GSAP 3.13+ (core only) for the intro and the colour tween. CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`.
- One GLB (`/assets/interactive-3d-product/product.glb`). The demo's `make-glb.mjs` generates it in Node: two `LatheGeometry` meshes (Body: 128 segments; Cap: 96) written as a binary glTF 2.0 by hand — JSON chunk padded with `0x20` to 4 bytes, BIN chunk with `POSITION` (FLOAT VEC3, with `min`/`max`), `NORMAL` (FLOAT VEC3) and indices (UNSIGNED_SHORT SCALAR, bufferView target 34963) per mesh; materials `Body` (`baseColorFactor [0.48,0.12,0.18,1]`, `metallicFactor 0.1`, `roughnessFactor 0.25`) and `Cap` (`[0.83,0.68,0.38,1]`, metallic 1.0, roughness 0.32). Nodes are named `Body` and `Cap`. Any GLB with a mesh named `Body` works as a drop-in.
**HTML structure:**
```html
<section class="prod">
<div class="prod__stage">
<div class="prod__canvas"></div> <!-- renderer canvas is appended here -->
<p class="prod__loading">Loading model…</p>
<div class="prod__hotspot" data-hotspot="cap"><span class="prod__dot"></span><span class="prod__label">Hand-turned brass cap</span></div>
<div class="prod__hotspot" data-hotspot="body"><span class="prod__dot"></span><span class="prod__label">Recycled glass, 120 ml</span></div>
</div>
<header class="prod__header">
<p class="prod__eyebrow">WebGL · GLTF · OrbitControls</p>
<h1 class="prod__title">Maison Nocturne</h1>
<p class="prod__copy">Drag to orbit, scroll to zoom…</p>
</header>
<div class="prod__swatches" role="group" aria-label="Choose a finish">
<button class="prod__swatch is-active" data-color="#7a1e2e" aria-pressed="true"><span class="prod__chip" style="--c:#7a1e2e"></span>Oxblood</button>
<button class="prod__swatch" data-color="#9fb08f" aria-pressed="false"><span class="prod__chip" style="--c:#9fb08f"></span>Sage</button>
<button class="prod__swatch" data-color="#1a1d2b" aria-pressed="false"><span class="prod__chip" style="--c:#1a1d2b"></span>Ink</button>
</div>
</section>
```
**CSS requirements:**
- `.prod`: `position: relative; min-height: 100vh; display: grid; grid-template-rows: 1fr auto; overflow: hidden; background: var(--bg, #0a0a0a); color: var(--text, #ededed);`
- `.prod__stage`: `position: absolute; inset: 0;` `.prod__canvas`: `position: absolute; inset: 0; touch-action: none; cursor: grab;` (`:active { cursor: grabbing }`); the canvas itself `width: 100%; height: 100%; display: block`.
- `.prod__stage::before`: a decorative floor glow — `radial-gradient(50% 50% at 50% 50%, rgba(255,255,255,.07), transparent 70%)`, `width: min(80vw, 900px); aspect-ratio: 2/1;` centred at `left: 50%; top: 62%`.
- `.prod__header`: `position: relative; z-index: 2; pointer-events: none; padding: clamp(1.5rem, 4vw, 3.5rem) clamp(1.25rem, 6vw, 5rem) 0; max-width: 36rem;` Title: display serif, `font-weight: 300; font-size: clamp(2.6rem, 6.5vw, 6.5rem); line-height: .92; letter-spacing: -0.04em`. Eyebrow: mono 11px uppercase `letter-spacing: .14em`, muted.
- `.prod__swatches`: `position: relative; z-index: 2; display: flex; gap: .5rem; flex-wrap: wrap; align-self: end; padding: 0 clamp(1.25rem, 6vw, 5rem) clamp(1.5rem, 4vw, 3rem);`
- `.prod__swatch`: pill, `border: 1px solid rgba(255,255,255,.18); border-radius: 999px; padding: .55rem 1rem .55rem .6rem; font-size: .82rem;` `.is-active { border-color: var(--accent, #d4ff3a); color: var(--text) }`; `.prod__chip`: 18px circle filled with `var(--c)`.
- `.prod__hotspot`: `position: absolute; left: 0; top: 0; display: flex; align-items: center; gap: .6rem; opacity: 0; pointer-events: none; will-change: transform, opacity;` `.prod__dot`: 12px circle filled with `var(--accent, #d4ff3a)` and a 4px halo of the same colour at 22% alpha. `.prod__label`: mono 11px uppercase with a 1.6rem × 1px leader line drawn by `::before`. The body hotspot is mirrored (`flex-direction: row-reverse`, leader on the right).
**Animation spec (exact values):**
1. **Renderer**: `new THREE.WebGLRenderer({ antialias: true, alpha: true })`, `setPixelRatio(Math.min(devicePixelRatio, 2))`, transparent clear colour, `shadowMap.enabled = true`, `shadowMap.type = THREE.PCFSoftShadowMap`, `toneMapping = THREE.ACESFilmicToneMapping`, `toneMappingExposure = 1.05`.
2. **Camera**: `PerspectiveCamera(35, aspect, 0.1, 60)` at `(3.4, 2.3, 5.4)`.
3. **OrbitControls**: `target (0, 1.25, 0)`, `enableDamping: true`, `dampingFactor: 0.06`, `autoRotate: true`, `autoRotateSpeed: 0.6`, `enablePan: false`, `minDistance: 4`, `maxDistance: 9`, `minPolarAngle: 0.85`, `maxPolarAngle: 1.5`.
4. **Lights**: key `DirectionalLight(0xffffff, 2.4)` at `(4, 6.5, 3.5)`, `castShadow`, `shadow.mapSize 1024²`, ortho shadow camera `±3`, `near 1 / far 20`, `bias -0.0004`. Fill `DirectionalLight(0xdfe8ff, 0.7)` at `(-5, 3, 2.5)`. Rim `DirectionalLight(0xfff1d6, 1.8)` at `(-2.5, 4, -5)`.
5. **Shadow catcher**: `Mesh(PlaneGeometry(14, 14), ShadowMaterial({ opacity: 0 }))`, `rotation.x = -Math.PI/2`, `receiveShadow = true`; opacity tweens to `0.3` in the intro.
6. **Environment (procedural PMREM)**: build a throwaway `Scene` with a `BoxGeometry(12, 8, 12)` `MeshBasicMaterial({ color: 0x17181b, side: BackSide })` and five `MeshBasicMaterial` planes whose `color.multiplyScalar(intensity)` makes them HDR emitters: top `PlaneGeometry(4,4)` white ×4.0 at `(0, 3.95, 0)`; key strip `(1.6, 5)` `0xfff4e2` ×3.0 at `(5.95, 1, 2)`; fill `(3,3)` `0xbfd0ff` ×1.4 at `(-5.95, 1.5, 1)`; lime rim `(0.5, 5)` `0xd4ff3a` ×2.2 at `(-1.5, 1, -5.95)`; floor bounce `(6, 0.3)` white ×1.2 at `(0, -0.5, 5.95)`; every plane `lookAt(0,0,0)`. Then `scene.environment = new THREE.PMREMGenerator(renderer).fromScene(room, 0.04).texture`; dispose the generator and the room geometry/materials immediately.
7. **Load**: `new GLTFLoader().load(url, …)`; traverse: `castShadow = true`, `material.envMapIntensity = 1.1` (Cap `1.4`); keep a reference to the `Body` mesh's material; set its colour to the active swatch; remove the loading text.
8. **Intro timeline** (`defaults: { ease: 'power3.out' }`): `tl.from(model.position, { y: -0.4, duration: 1.4 }, 0)`; `tl.from(model.scale, { x: .9, y: .9, z: .9, duration: 1.4 }, 0)`; `tl.from(model.rotation, { y: -1.1, duration: 1.6 }, 0)`; `tl.to(catcherMaterial, { opacity: 0.3, duration: 1.2 }, 0.2)`; `tl.to(hotspots, { opacity: 1, duration: 0.6, stagger: 0.15, ease: 'power2.out' }, 0.9)`.
9. **Swatch click**: toggle `.is-active`/`aria-pressed`; `const c = new THREE.Color(hex); gsap.to(bodyMaterial.color, { r: c.r, g: c.g, b: c.b, duration: 0.6, ease: 'power2.inOut', overwrite: true })`.
10. **Hotspots (every frame)**: cap anchor = model-local `(0, 2.72, 0)` → `localToWorld`; body anchor = `(dir.x, 0.9, dir.z)` where `dir` is the horizontal unit vector from the orbit target to the camera (so the dot always sits on the side facing the viewer). `v.project(camera)`; `x = (v.x*0.5+0.5)*width`, `y = (-v.y*0.5+0.5)*height`; `el.style.transform = translate(x px, y px) translate(-6px,-6px)`; hide when `v.z >= 1`.
11. **Loop**: `const moved = controls.update(); if (moved || needsRender || controls.autoRotate) { updateHotspots(); renderer.render(scene, camera); }`.
**Interaction:** Pointer drag orbits (OrbitControls, damping 0.06 — 0.06 coasts ~0.5s after release); wheel / pinch zooms between distance 4 and 9; panning is disabled. Autorotation (0.6) continues while the user is not dragging (OrbitControls pauses it during interaction automatically). Swatch click = 0.6s colour cross-fade. Hover on swatches brightens the border over 0.25s.
**Responsive behavior:** Under 768px (`gsap.matchMedia('(max-width: 767px)')`): camera `fov` 35 → 44 and orbit target `y` 1.25 → 1.35 so the whole flask fits a portrait viewport; the header spans full width and the copy caps at 30ch. Touch orbits/pinch-zooms natively (`touch-action: none` on the canvas wrapper). Hotspot labels drop to 10px.
**Accessibility:** With `prefers-reduced-motion: reduce`: no intro animation, `autoRotate = false`, `enableDamping = false`, the colour change is instant (duration 0), and rendering is on demand (`controls.addEventListener('change', render)` plus one render after load and on resize) — no rAF loop. The canvas wrapper is `aria-hidden`; the swatch group has `role="group"` + `aria-label` and each button carries `aria-pressed`. Hotspots are decorative (`aria-hidden`) because their text is repeated in the page copy. Visible focus rings (`outline: 2px solid var(--accent); outline-offset: 3px`).
**Performance rules:** DPR capped at `Math.min(devicePixelRatio, 2)` (halved in the library's hover preview). One 1024² shadow map from the key light only. PMREM is generated once, before the model arrives, and the generator is disposed. Skip renders when nothing moved unless autorotating. Stop the rAF loop on `ml:pause` / `visibilitychange` and restart on `ml:resume`. On cleanup: stop the loop, `ctx.revert()`, `controls.dispose()`, traverse the scene disposing geometries and materials, dispose the environment texture, `renderer.dispose()`, remove the canvas. Use a `ResizeObserver` on the canvas wrapper rather than `window.resize`.
**Customization knobs:**
- `MODEL_URL` — any GLB; name the tintable mesh `Body`.
- `AUTO_ROTATE_SPEED` (0.6) and `DAMPING` (0.06).
- `COLOR_DURATION` (0.6) and the swatch `data-color` hex values.
- `SHADOW_OPACITY` (0.3) and the key light position (shadow direction).
- `toneMappingExposure` (1.05) — overall brightness of the tone-mapped frame.
- `--accent` (#d4ff3a) — hotspot dots and the active swatch outline.
- Environment panel colours/intensities — swap the lime strip for a brand colour.
- `minPolarAngle` / `maxPolarAngle` and `minDistance` / `maxDistance`.
**Acceptance criteria:**
- [ ] Reflections are visible on the body and cap before the intro finishes (environment built before load).
- [ ] The flask rises, scales and turns in over ~1.6s and then rotates slowly on its own; dragging stops autorotation and the camera coasts with damping.
- [ ] Zoom is clamped between 4 and 9 units; the camera never passes above the pole or below the floor.
- [ ] Clicking a swatch changes only the body colour, over 0.6s, with the active pill outlined in the accent colour (#d4ff3a).
- [ ] Both hotspots stay glued to their 3D anchors while orbiting; the body label stays on the side facing the camera.
- [ ] A soft shadow sits under the bottle on a transparent floor; the page background shows through.
- [ ] Reduced motion: no intro, no autorotation, instant colour change, still fully orbitable.
- [ ] No console errors; the GLB loads with HTTP 200. Add this effect to my existing site/section: **Interactive 3D Product (GLTF) Rotate + Orbit**. Mount a Three.js `WebGLRenderer({ antialias: true, alpha: true })` (DPR ≤ 2, ACES tone mapping at `toneMappingExposure 1.05`, PCFSoft shadows) inside my product container and load my GLB with `GLTFLoader`. Use `OrbitControls` with `enableDamping: true, dampingFactor: 0.06, autoRotate: true, autoRotateSpeed: 0.6, enablePan: false, minDistance: 4, maxDistance: 9, minPolarAngle: 0.85, maxPolarAngle: 1.5`. Light it with a key `DirectionalLight(0xffffff, 2.4)` at `(4, 6.5, 3.5)` casting a 1024² shadow, a fill `(0xdfe8ff, 0.7)` at `(-5, 3, 2.5)` and a rim `(0xfff1d6, 1.8)` at `(-2.5, 4, -5)`, plus a `ShadowMaterial({ opacity: 0.3 })` floor plane. Generate reflections with `PMREMGenerator.fromScene()` over a small box room containing over-bright `MeshBasicMaterial` panels (no HDR file). Animate the model in with GSAP (`position.y -0.4→0`, `scale 0.9→1`, `rotation.y -1.1→0`, 1.4–1.6s, `power3.out`). Wire my colour swatches to `gsap.to(material.color, { r, g, b, duration: 0.6, ease: 'power2.inOut' })` using `new THREE.Color(hex)`. Project one or two 3D anchor points to 2D each frame for hotspot labels (dots and active-swatch outline in `#d4ff3a`). Respect `prefers-reduced-motion` (no autorotation/intro, render on demand), pause the loop when hidden, and dispose everything on unmount. Keep my fonts, colours and layout. ### EFFECT (React / Next.js): Interactive 3D Product (GLTF) Rotate + Orbit
Build **Interactive 3D Product (GLTF) Rotate + Orbit** as a self-contained React component for a Next.js App Router project (also valid in Vite + React). Follow the specification below exactly; the vanilla reference implementation at the end is the source of truth for values and structure.
**Setup**
- `npm i gsap @gsap/react three`
- File: `components/Interactive3DProductGLTFRotateOrbit.tsx`, first line `"use client";` (GSAP touches the DOM).
- Imports:
```tsx
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import * as THREE from "three";
gsap.registerPlugin(useGSAP);
export default function Interactive3DProductGLTFRotateOrbit() {
const scope = useRef<HTMLDivElement>(null);
useGSAP(() => {
// 1. query elements inside `scope.current` (never document-wide)
// 2. wait for document.fonts.ready before any SplitText
// 3. build the timeline / ScrollTriggers exactly as in the specification
// 4. return nothing — useGSAP reverts everything automatically on unmount
}, { scope });
return (
<div ref={scope} className="prod">
{/* markup from the HTML structure section, class → className */}
</div>
);
}
```
**React-specific rules**
- Everything GSAP-related lives inside `useGSAP(() => {...}, { scope })`; it handles cleanup (`ctx.revert()`) on unmount and in React Strict Mode double-invocation.
- Use refs or `gsap.utils.selector(scope)` instead of `document.querySelector`.
- Event listeners added inside useGSAP must use `contextSafe` (`const { contextSafe } = useGSAP({ scope })`) so their tweens are reverted too.
- ScrollTrigger: call `ScrollTrigger.refresh()` after images/fonts load; if the page uses Lenis, sync it once at the app root (`lenis.on('scroll', ScrollTrigger.update)`).
- Styles: copy the CSS requirements into a CSS Module (`Interactive3DProductGLTFRotateOrbit.module.css`) or Tailwind; keep the class names so the spec maps 1:1.
- Respect `prefers-reduced-motion` with `gsap.matchMedia()` as in the spec.
- Three.js: create renderer/scene inside a \`useEffect\` with a cleanup that calls \`renderer.dispose()\` and disposes geometries/materials; cap DPR at \`Math.min(devicePixelRatio, 2)\`.
---
### EFFECT: Interactive 3D Product (GLTF) Rotate + Orbit
**What it does (visual):** A full-viewport dark stage with a large thin serif headline top-left ("Maison Nocturne"), a short muted paragraph, and three pill-shaped colour swatches bottom-left. In the centre, a glossy perfume flask (a GLB) rises 0.4 units from below while scaling 0.9 → 1 and turning ~63° over 1.4–1.6s, and a soft contact shadow (opacity 0.3) fades in beneath it on an otherwise invisible floor. The flask then idles on a slow turntable (`autoRotateSpeed` 0.6; one revolution ≈ 100s at the default). The user can drag to orbit (the camera coasts to a stop with damping 0.06) and scroll/pinch to zoom between fixed limits; it is impossible to look from directly above or from under the floor. Two small accent-coloured (#d4ff3a) dots with hairline leader lines and mono labels ("Hand-turned brass cap", "Recycled glass, 120 ml") stick to the cap and to the side of the bottle that currently faces the camera, following it as it rotates. Clicking a swatch cross-fades the body's PBR colour to the new finish over 0.6s while the brass cap stays metallic. The whole frame is ACES tone-mapped at exposure 1.05. Reflections come from a studio-like environment (bright top panel, warm key strip, cool fill, a lime rim accent) generated at runtime, not from an HDR file.
**Tech stack:**
- Three.js r160+ (`three`, `three/addons/controls/OrbitControls.js`, `three/addons/loaders/GLTFLoader.js`) via importmap or `npm i three`.
- GSAP 3.13+ (core only) for the intro and the colour tween. CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`.
- One GLB (`/assets/interactive-3d-product/product.glb`). The demo's `make-glb.mjs` generates it in Node: two `LatheGeometry` meshes (Body: 128 segments; Cap: 96) written as a binary glTF 2.0 by hand — JSON chunk padded with `0x20` to 4 bytes, BIN chunk with `POSITION` (FLOAT VEC3, with `min`/`max`), `NORMAL` (FLOAT VEC3) and indices (UNSIGNED_SHORT SCALAR, bufferView target 34963) per mesh; materials `Body` (`baseColorFactor [0.48,0.12,0.18,1]`, `metallicFactor 0.1`, `roughnessFactor 0.25`) and `Cap` (`[0.83,0.68,0.38,1]`, metallic 1.0, roughness 0.32). Nodes are named `Body` and `Cap`. Any GLB with a mesh named `Body` works as a drop-in.
**HTML structure:**
```html
<section class="prod">
<div class="prod__stage">
<div class="prod__canvas"></div> <!-- renderer canvas is appended here -->
<p class="prod__loading">Loading model…</p>
<div class="prod__hotspot" data-hotspot="cap"><span class="prod__dot"></span><span class="prod__label">Hand-turned brass cap</span></div>
<div class="prod__hotspot" data-hotspot="body"><span class="prod__dot"></span><span class="prod__label">Recycled glass, 120 ml</span></div>
</div>
<header class="prod__header">
<p class="prod__eyebrow">WebGL · GLTF · OrbitControls</p>
<h1 class="prod__title">Maison Nocturne</h1>
<p class="prod__copy">Drag to orbit, scroll to zoom…</p>
</header>
<div class="prod__swatches" role="group" aria-label="Choose a finish">
<button class="prod__swatch is-active" data-color="#7a1e2e" aria-pressed="true"><span class="prod__chip" style="--c:#7a1e2e"></span>Oxblood</button>
<button class="prod__swatch" data-color="#9fb08f" aria-pressed="false"><span class="prod__chip" style="--c:#9fb08f"></span>Sage</button>
<button class="prod__swatch" data-color="#1a1d2b" aria-pressed="false"><span class="prod__chip" style="--c:#1a1d2b"></span>Ink</button>
</div>
</section>
```
**CSS requirements:**
- `.prod`: `position: relative; min-height: 100vh; display: grid; grid-template-rows: 1fr auto; overflow: hidden; background: var(--bg, #0a0a0a); color: var(--text, #ededed);`
- `.prod__stage`: `position: absolute; inset: 0;` `.prod__canvas`: `position: absolute; inset: 0; touch-action: none; cursor: grab;` (`:active { cursor: grabbing }`); the canvas itself `width: 100%; height: 100%; display: block`.
- `.prod__stage::before`: a decorative floor glow — `radial-gradient(50% 50% at 50% 50%, rgba(255,255,255,.07), transparent 70%)`, `width: min(80vw, 900px); aspect-ratio: 2/1;` centred at `left: 50%; top: 62%`.
- `.prod__header`: `position: relative; z-index: 2; pointer-events: none; padding: clamp(1.5rem, 4vw, 3.5rem) clamp(1.25rem, 6vw, 5rem) 0; max-width: 36rem;` Title: display serif, `font-weight: 300; font-size: clamp(2.6rem, 6.5vw, 6.5rem); line-height: .92; letter-spacing: -0.04em`. Eyebrow: mono 11px uppercase `letter-spacing: .14em`, muted.
- `.prod__swatches`: `position: relative; z-index: 2; display: flex; gap: .5rem; flex-wrap: wrap; align-self: end; padding: 0 clamp(1.25rem, 6vw, 5rem) clamp(1.5rem, 4vw, 3rem);`
- `.prod__swatch`: pill, `border: 1px solid rgba(255,255,255,.18); border-radius: 999px; padding: .55rem 1rem .55rem .6rem; font-size: .82rem;` `.is-active { border-color: var(--accent, #d4ff3a); color: var(--text) }`; `.prod__chip`: 18px circle filled with `var(--c)`.
- `.prod__hotspot`: `position: absolute; left: 0; top: 0; display: flex; align-items: center; gap: .6rem; opacity: 0; pointer-events: none; will-change: transform, opacity;` `.prod__dot`: 12px circle filled with `var(--accent, #d4ff3a)` and a 4px halo of the same colour at 22% alpha. `.prod__label`: mono 11px uppercase with a 1.6rem × 1px leader line drawn by `::before`. The body hotspot is mirrored (`flex-direction: row-reverse`, leader on the right).
**Animation spec (exact values):**
1. **Renderer**: `new THREE.WebGLRenderer({ antialias: true, alpha: true })`, `setPixelRatio(Math.min(devicePixelRatio, 2))`, transparent clear colour, `shadowMap.enabled = true`, `shadowMap.type = THREE.PCFSoftShadowMap`, `toneMapping = THREE.ACESFilmicToneMapping`, `toneMappingExposure = 1.05`.
2. **Camera**: `PerspectiveCamera(35, aspect, 0.1, 60)` at `(3.4, 2.3, 5.4)`.
3. **OrbitControls**: `target (0, 1.25, 0)`, `enableDamping: true`, `dampingFactor: 0.06`, `autoRotate: true`, `autoRotateSpeed: 0.6`, `enablePan: false`, `minDistance: 4`, `maxDistance: 9`, `minPolarAngle: 0.85`, `maxPolarAngle: 1.5`.
4. **Lights**: key `DirectionalLight(0xffffff, 2.4)` at `(4, 6.5, 3.5)`, `castShadow`, `shadow.mapSize 1024²`, ortho shadow camera `±3`, `near 1 / far 20`, `bias -0.0004`. Fill `DirectionalLight(0xdfe8ff, 0.7)` at `(-5, 3, 2.5)`. Rim `DirectionalLight(0xfff1d6, 1.8)` at `(-2.5, 4, -5)`.
5. **Shadow catcher**: `Mesh(PlaneGeometry(14, 14), ShadowMaterial({ opacity: 0 }))`, `rotation.x = -Math.PI/2`, `receiveShadow = true`; opacity tweens to `0.3` in the intro.
6. **Environment (procedural PMREM)**: build a throwaway `Scene` with a `BoxGeometry(12, 8, 12)` `MeshBasicMaterial({ color: 0x17181b, side: BackSide })` and five `MeshBasicMaterial` planes whose `color.multiplyScalar(intensity)` makes them HDR emitters: top `PlaneGeometry(4,4)` white ×4.0 at `(0, 3.95, 0)`; key strip `(1.6, 5)` `0xfff4e2` ×3.0 at `(5.95, 1, 2)`; fill `(3,3)` `0xbfd0ff` ×1.4 at `(-5.95, 1.5, 1)`; lime rim `(0.5, 5)` `0xd4ff3a` ×2.2 at `(-1.5, 1, -5.95)`; floor bounce `(6, 0.3)` white ×1.2 at `(0, -0.5, 5.95)`; every plane `lookAt(0,0,0)`. Then `scene.environment = new THREE.PMREMGenerator(renderer).fromScene(room, 0.04).texture`; dispose the generator and the room geometry/materials immediately.
7. **Load**: `new GLTFLoader().load(url, …)`; traverse: `castShadow = true`, `material.envMapIntensity = 1.1` (Cap `1.4`); keep a reference to the `Body` mesh's material; set its colour to the active swatch; remove the loading text.
8. **Intro timeline** (`defaults: { ease: 'power3.out' }`): `tl.from(model.position, { y: -0.4, duration: 1.4 }, 0)`; `tl.from(model.scale, { x: .9, y: .9, z: .9, duration: 1.4 }, 0)`; `tl.from(model.rotation, { y: -1.1, duration: 1.6 }, 0)`; `tl.to(catcherMaterial, { opacity: 0.3, duration: 1.2 }, 0.2)`; `tl.to(hotspots, { opacity: 1, duration: 0.6, stagger: 0.15, ease: 'power2.out' }, 0.9)`.
9. **Swatch click**: toggle `.is-active`/`aria-pressed`; `const c = new THREE.Color(hex); gsap.to(bodyMaterial.color, { r: c.r, g: c.g, b: c.b, duration: 0.6, ease: 'power2.inOut', overwrite: true })`.
10. **Hotspots (every frame)**: cap anchor = model-local `(0, 2.72, 0)` → `localToWorld`; body anchor = `(dir.x, 0.9, dir.z)` where `dir` is the horizontal unit vector from the orbit target to the camera (so the dot always sits on the side facing the viewer). `v.project(camera)`; `x = (v.x*0.5+0.5)*width`, `y = (-v.y*0.5+0.5)*height`; `el.style.transform = translate(x px, y px) translate(-6px,-6px)`; hide when `v.z >= 1`.
11. **Loop**: `const moved = controls.update(); if (moved || needsRender || controls.autoRotate) { updateHotspots(); renderer.render(scene, camera); }`.
**Interaction:** Pointer drag orbits (OrbitControls, damping 0.06 — 0.06 coasts ~0.5s after release); wheel / pinch zooms between distance 4 and 9; panning is disabled. Autorotation (0.6) continues while the user is not dragging (OrbitControls pauses it during interaction automatically). Swatch click = 0.6s colour cross-fade. Hover on swatches brightens the border over 0.25s.
**Responsive behavior:** Under 768px (`gsap.matchMedia('(max-width: 767px)')`): camera `fov` 35 → 44 and orbit target `y` 1.25 → 1.35 so the whole flask fits a portrait viewport; the header spans full width and the copy caps at 30ch. Touch orbits/pinch-zooms natively (`touch-action: none` on the canvas wrapper). Hotspot labels drop to 10px.
**Accessibility:** With `prefers-reduced-motion: reduce`: no intro animation, `autoRotate = false`, `enableDamping = false`, the colour change is instant (duration 0), and rendering is on demand (`controls.addEventListener('change', render)` plus one render after load and on resize) — no rAF loop. The canvas wrapper is `aria-hidden`; the swatch group has `role="group"` + `aria-label` and each button carries `aria-pressed`. Hotspots are decorative (`aria-hidden`) because their text is repeated in the page copy. Visible focus rings (`outline: 2px solid var(--accent); outline-offset: 3px`).
**Performance rules:** DPR capped at `Math.min(devicePixelRatio, 2)` (halved in the library's hover preview). One 1024² shadow map from the key light only. PMREM is generated once, before the model arrives, and the generator is disposed. Skip renders when nothing moved unless autorotating. Stop the rAF loop on `ml:pause` / `visibilitychange` and restart on `ml:resume`. On cleanup: stop the loop, `ctx.revert()`, `controls.dispose()`, traverse the scene disposing geometries and materials, dispose the environment texture, `renderer.dispose()`, remove the canvas. Use a `ResizeObserver` on the canvas wrapper rather than `window.resize`.
**Customization knobs:**
- `MODEL_URL` — any GLB; name the tintable mesh `Body`.
- `AUTO_ROTATE_SPEED` (0.6) and `DAMPING` (0.06).
- `COLOR_DURATION` (0.6) and the swatch `data-color` hex values.
- `SHADOW_OPACITY` (0.3) and the key light position (shadow direction).
- `toneMappingExposure` (1.05) — overall brightness of the tone-mapped frame.
- `--accent` (#d4ff3a) — hotspot dots and the active swatch outline.
- Environment panel colours/intensities — swap the lime strip for a brand colour.
- `minPolarAngle` / `maxPolarAngle` and `minDistance` / `maxDistance`.
**Acceptance criteria:**
- [ ] Reflections are visible on the body and cap before the intro finishes (environment built before load).
- [ ] The flask rises, scales and turns in over ~1.6s and then rotates slowly on its own; dragging stops autorotation and the camera coasts with damping.
- [ ] Zoom is clamped between 4 and 9 units; the camera never passes above the pole or below the floor.
- [ ] Clicking a swatch changes only the body colour, over 0.6s, with the active pill outlined in the accent colour (#d4ff3a).
- [ ] Both hotspots stay glued to their 3D anchors while orbiting; the body label stays on the side facing the camera.
- [ ] A soft shadow sits under the bottle on a transparent floor; the page background shows through.
- [ ] Reduced motion: no intro, no autorotation, instant colour change, still fully orbitable.
- [ ] No console errors; the GLB loads with HTTP 200.
---
**Reference implementation (vanilla, converts 1:1 — same class names, same values):**
```html
<section class="prod" aria-label="Interactive 3D product viewer">
<div class="prod__stage">
<div class="prod__canvas" aria-hidden="true"></div>
<p class="prod__loading" aria-live="polite">Loading model…</p>
<div class="prod__hotspot" data-hotspot="cap" aria-hidden="true">
<span class="prod__dot"></span>
<span class="prod__label">Hand-turned brass cap</span>
</div>
<div class="prod__hotspot" data-hotspot="body" aria-hidden="true">
<span class="prod__dot"></span>
<span class="prod__label">Recycled glass, 120 ml</span>
</div>
</div>
<header class="prod__header">
<p class="prod__eyebrow">WebGL · GLTF · OrbitControls</p>
<h1 class="prod__title">Maison Nocturne</h1>
<p class="prod__copy">Drag to orbit, scroll to zoom. The flask idles on a slow turntable and settles with damping when you let go.</p>
</header>
<div class="prod__swatches" role="group" aria-label="Choose a finish">
<button class="prod__swatch is-active" type="button" data-color="#7a1e2e" aria-pressed="true"><span class="prod__chip" style="--c:#7a1e2e"></span>Oxblood</button>
<button class="prod__swatch" type="button" data-color="#9fb08f" aria-pressed="false"><span class="prod__chip" style="--c:#9fb08f"></span>Sage</button>
<button class="prod__swatch" type="button" data-color="#1a1d2b" aria-pressed="false"><span class="prod__chip" style="--c:#1a1d2b"></span>Ink</button>
</div>
</section>
```
```css
.prod { --accent: var(--knob-accent, var(--accent)); position: relative; min-height: 100vh; display: grid; grid-template-rows: 1fr auto; background: var(--bg); color: var(--text); overflow: hidden; }
.prod__stage { position: absolute; inset: 0; }
.prod__canvas { position: absolute; inset: 0; touch-action: none; cursor: grab; }
.prod__canvas:active { cursor: grabbing; }
.prod__canvas canvas { width: 100%; height: 100%; display: block; }
/* radial floor glow behind the shadow catcher */
.prod__stage::before {
content: ''; position: absolute; left: 50%; top: 62%; width: min(80vw, 900px); aspect-ratio: 2 / 1; transform: translate(-50%, -50%);
background: radial-gradient(50% 50% at 50% 50%, color-mix(in srgb, var(--text) 7%, transparent), transparent 70%);
pointer-events: none;
}
.prod__loading { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); font-family: var(--font-mono); font-size: 11px; letter-spacing: .14em; text-transform: uppercase; color: var(--muted); }
.prod__header { position: relative; z-index: 2; padding: clamp(1.5rem, 4vw, 3.5rem) clamp(1.25rem, 6vw, 5rem) 0; pointer-events: none; max-width: 36rem; }
.prod__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.1rem; }
.prod__title { font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30; font-size: clamp(2.6rem, 6.5vw, 6.5rem); line-height: .92; letter-spacing: -0.04em; }
.prod__copy { margin-top: 1.1rem; max-width: 34ch; color: var(--muted); font-size: clamp(.9rem, 1.1vw, 1.05rem); line-height: 1.5; }
.prod__swatches { position: relative; z-index: 2; display: flex; gap: .5rem; flex-wrap: wrap; padding: 0 clamp(1.25rem, 6vw, 5rem) clamp(1.5rem, 4vw, 3rem); align-self: end; }
.prod__swatch {
display: inline-flex; align-items: center; gap: .6rem; padding: .55rem 1rem .55rem .6rem; border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--text) 18%, transparent); font-size: .82rem; font-weight: 500; color: var(--muted);
transition: color .25s, border-color .25s, background .25s;
}
.prod__swatch:hover { color: var(--text); border-color: color-mix(in srgb, var(--text) 40%, transparent); }
.prod__swatch.is-active { color: var(--text); border-color: var(--accent); }
.prod__swatch:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.prod__chip { width: 18px; height: 18px; border-radius: 50%; background: var(--c); box-shadow: inset 0 0 0 1px rgba(255,255,255,.12); }
/* hotspots: positioned by JS from projected 3D points */
.prod__hotspot { position: absolute; left: 0; top: 0; display: flex; align-items: center; gap: .6rem; transform: translate(-6px, -6px); opacity: 0; pointer-events: none; will-change: transform, opacity; }
.prod__dot { width: 12px; height: 12px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 22%, transparent); flex: none; }
.prod__label { position: relative; padding-left: 2.2rem; font-family: var(--font-mono); font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--text); white-space: nowrap; }
.prod__label::before { content: ''; position: absolute; left: 0; top: 50%; width: 1.6rem; height: 1px; background: color-mix(in srgb, var(--text) 40%, transparent); }
.prod__hotspot[data-hotspot="body"] { flex-direction: row-reverse; }
.prod__hotspot[data-hotspot="body"] .prod__label { padding-left: 0; padding-right: 2.2rem; }
.prod__hotspot[data-hotspot="body"] .prod__label::before { left: auto; right: 0; }
@media (max-width: 767px) {
.prod__header { max-width: 100%; }
.prod__copy { max-width: 30ch; }
.prod__hotspot .prod__label { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.prod__swatch { transition: none; }
}
```
```js
// Interactive 3D product — GLTF flask, OrbitControls, 3-point studio light, procedural PMREM, swatches, hotspots.
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { autoRotateSpeed: 0.6, damping: 0.06, colorDuration: 0.6, shadowOpacity: 0.3, exposure: 1.05, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const MODEL_URL = '/assets/interactive-3d-product/product.glb';
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.prod');
const mount = section.querySelector('.prod__canvas');
const loadingEl = section.querySelector('.prod__loading');
const hotspotEls = { cap: section.querySelector('[data-hotspot="cap"]'), body: section.querySelector('[data-hotspot="body"]') };
const swatches = [...section.querySelectorAll('.prod__swatch')];
// ---------------------------------------------------------------- renderer (created once; knobs update its params)
const dprCap = window.MotionLab?.preview ? 1 : 2;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio, dprCap));
renderer.setClearColor(0x000000, 0);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = DEFAULTS.exposure;
mount.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(35, 1, 0.1, 60);
camera.position.set(3.4, 2.3, 5.4);
// ---------------------------------------------------------------- controls
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 1.25, 0);
controls.enableDamping = !reduced;
controls.dampingFactor = DEFAULTS.damping;
controls.autoRotate = !reduced;
controls.autoRotateSpeed = DEFAULTS.autoRotateSpeed;
controls.enablePan = false;
controls.minDistance = 4;
controls.maxDistance = 9;
controls.minPolarAngle = 0.85; // never look from directly above
controls.maxPolarAngle = 1.5; // never dip below the floor
controls.update();
// ---------------------------------------------------------------- lights (key / fill / rim)
const key = new THREE.DirectionalLight(0xffffff, 2.4);
key.position.set(4, 6.5, 3.5);
key.castShadow = true;
key.shadow.mapSize.set(1024, 1024);
key.shadow.camera.near = 1; key.shadow.camera.far = 20;
key.shadow.camera.left = -3; key.shadow.camera.right = 3; key.shadow.camera.top = 3; key.shadow.camera.bottom = -3;
key.shadow.bias = -0.0004;
scene.add(key);
const fill = new THREE.DirectionalLight(0xdfe8ff, 0.7);
fill.position.set(-5, 3, 2.5);
scene.add(fill);
const rim = new THREE.DirectionalLight(0xfff1d6, 1.8);
rim.position.set(-2.5, 4, -5);
scene.add(rim);
// Shadow catcher: invisible plane that only shows the shadow it receives.
const catcherMat = new THREE.ShadowMaterial({ opacity: reduced ? DEFAULTS.shadowOpacity : 0 });
const catcher = new THREE.Mesh(new THREE.PlaneGeometry(14, 14), catcherMat);
catcher.rotation.x = -Math.PI / 2;
catcher.receiveShadow = true;
scene.add(catcher);
// ---------------------------------------------------------------- procedural environment (PMREM from a lit room, no HDR file)
function buildEnvironment() {
const room = new THREE.Scene();
const disposables = [];
const add = (geo, color, intensity, pos, lookAt) => {
const mat = new THREE.MeshBasicMaterial({ color });
mat.color.multiplyScalar(intensity); // >1 = HDR emitter for the PMREM pass
const mesh = new THREE.Mesh(geo, mat);
mesh.position.copy(pos);
if (lookAt) mesh.lookAt(lookAt);
room.add(mesh);
disposables.push(geo, mat);
};
// dark walls
const wallsMat = new THREE.MeshBasicMaterial({ color: 0x17181b, side: THREE.BackSide });
const walls = new THREE.Mesh(new THREE.BoxGeometry(12, 8, 12), wallsMat);
room.add(walls); disposables.push(walls.geometry, wallsMat);
const c = new THREE.Vector3(0, 0, 0);
add(new THREE.PlaneGeometry(4, 4), 0xffffff, 4.0, new THREE.Vector3(0, 3.95, 0), c); // soft top light
add(new THREE.PlaneGeometry(1.6, 5), 0xfff4e2, 3.0, new THREE.Vector3(5.95, 1, 2), c); // warm key strip
add(new THREE.PlaneGeometry(3, 3), 0xbfd0ff, 1.4, new THREE.Vector3(-5.95, 1.5, 1), c); // cool fill
add(new THREE.PlaneGeometry(0.5, 5), 0xd4ff3a, 2.2, new THREE.Vector3(-1.5, 1, -5.95), c); // lime rim accent
add(new THREE.PlaneGeometry(6, 0.3), 0xffffff, 1.2, new THREE.Vector3(0, -0.5, 5.95), c); // thin floor-level bounce
const pmrem = new THREE.PMREMGenerator(renderer);
const envTex = pmrem.fromScene(room, 0.04).texture;
pmrem.dispose();
disposables.forEach((d) => d.dispose());
return envTex;
}
const envTexture = buildEnvironment();
scene.environment = envTexture;
// ---------------------------------------------------------------- model
let model = null;
let bodyMaterial = null;
let needsRender = true;
let introDone = reduced; // once the intro has landed, knob changes set the shadow opacity directly
const loader = new GLTFLoader();
loader.load(MODEL_URL, (gltf) => {
model = gltf.scene;
model.traverse((obj) => {
if (!obj.isMesh) return;
obj.castShadow = true;
obj.material.envMapIntensity = obj.name === 'Cap' ? 1.4 : 1.1;
if (obj.name === 'Body') bodyMaterial = obj.material;
});
scene.add(model);
loadingEl.remove();
const initial = swatches.find((b) => b.classList.contains('is-active'))?.dataset.color;
if (bodyMaterial && initial) bodyMaterial.color.set(initial);
introReveal();
needsRender = true;
}, undefined, (err) => {
loadingEl.textContent = 'The model failed to load.';
console.error('[interactive-3d-product] GLB load error', err);
});
// ---------------------------------------------------------------- intro (plays once after load, not on Playground rebuilds)
const introCtx = gsap.context(() => {});
function introReveal() {
if (reduced) {
gsap.set([hotspotEls.cap, hotspotEls.body], { opacity: 1 });
return;
}
introCtx.add(() => {
const tl = gsap.timeline({ defaults: { ease: 'power3.out' }, onComplete: () => { introDone = true; } });
tl.from(model.position, { y: -0.4, duration: 1.4 }, 0)
.from(model.scale, { x: 0.9, y: 0.9, z: 0.9, duration: 1.4 }, 0)
.from(model.rotation, { y: -1.1, duration: 1.6 }, 0)
.to(catcherMat, { opacity: () => K('shadowOpacity'), duration: 1.2 }, 0.2)
.to([hotspotEls.cap, hotspotEls.body], { opacity: 1, duration: 0.6, stagger: 0.15, ease: 'power2.out' }, 0.9);
});
}
// ---------------------------------------------------------------- build: read knobs → controls / renderer / shadow, (re)wire swatches + matchMedia
let ctx;
function build() {
ctx?.revert();
section.style.setProperty('--knob-accent', K('accent'));
controls.autoRotateSpeed = K('autoRotateSpeed');
controls.dampingFactor = K('damping');
renderer.toneMappingExposure = K('exposure');
if (introDone) catcherMat.opacity = K('shadowOpacity');
const colorDuration = reduced ? 0 : K('colorDuration');
ctx = gsap.context(() => {
const mm = gsap.matchMedia();
mm.add('(max-width: 767px)', () => {
camera.fov = 44; camera.updateProjectionMatrix();
controls.target.set(0, 1.35, 0);
needsRender = true;
return () => { camera.fov = 35; camera.updateProjectionMatrix(); controls.target.set(0, 1.25, 0); needsRender = true; };
});
const ac = new AbortController();
swatches.forEach((btn) => {
btn.addEventListener('click', () => {
swatches.forEach((b) => { b.classList.toggle('is-active', b === btn); b.setAttribute('aria-pressed', String(b === btn)); });
if (!bodyMaterial) return;
const target = new THREE.Color(btn.dataset.color);
gsap.to(bodyMaterial.color, { r: target.r, g: target.g, b: target.b, duration: colorDuration, ease: 'power2.inOut', overwrite: true, onUpdate: () => { needsRender = true; } });
}, { signal: ac.signal });
});
return () => ac.abort(); // remove the click handlers when the context is reverted (no stacked handlers on rebuild)
});
// a colour tween that was mid-flight when the context reverted rewound: land on the active swatch
const active = swatches.find((b) => b.classList.contains('is-active'))?.dataset.color;
if (bodyMaterial && active) bodyMaterial.color.set(active);
needsRender = true;
if (reduced) renderOnce();
}
// Project 3D anchor points to 2D and position the DOM labels.
const vCap = new THREE.Vector3();
const vBody = new THREE.Vector3();
const camDir = new THREE.Vector3();
function updateHotspots() {
if (!model) return;
const w = mount.clientWidth, h = mount.clientHeight;
// cap: fixed local point on top of the cap
vCap.set(0, 2.72, 0); model.localToWorld(vCap);
// body: point on the flask wall that currently faces the camera (height 0.9, radius 1)
camDir.subVectors(camera.position, controls.target); camDir.y = 0; camDir.normalize();
vBody.set(camDir.x * 1.0, 0.9, camDir.z * 1.0); model.localToWorld(vBody);
for (const [v, el] of [[vCap, hotspotEls.cap], [vBody, hotspotEls.body]]) {
v.project(camera);
const x = (v.x * 0.5 + 0.5) * w;
const y = (-v.y * 0.5 + 0.5) * h;
el.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) translate(-6px, -6px)`;
el.style.visibility = v.z < 1 ? 'visible' : 'hidden';
}
}
// ---------------------------------------------------------------- loop / pause / resize
let raf = 0, running = false;
function frame() {
raf = requestAnimationFrame(frame);
const moved = controls.update();
if (moved || needsRender || controls.autoRotate) {
updateHotspots();
renderer.render(scene, camera);
needsRender = false;
}
}
function start() { if (running || reduced) return; running = true; raf = requestAnimationFrame(frame); }
function stop() { running = false; cancelAnimationFrame(raf); }
function renderOnce() { controls.update(); updateHotspots(); renderer.render(scene, camera); }
function resize() {
const w = mount.clientWidth, h = mount.clientHeight;
if (!w || !h) return;
camera.aspect = w / h; camera.updateProjectionMatrix();
renderer.setSize(w, h, false);
needsRender = true;
if (reduced) renderOnce();
}
const ro = new ResizeObserver(resize);
ro.observe(mount);
build();
resize();
if (reduced) {
// No autoplay: render only when the user orbits or the model finishes loading.
controls.addEventListener('change', renderOnce);
loader.manager.onLoad = renderOnce;
} else {
start();
}
const onPause = () => stop();
const onResume = () => start();
const onVisibility = () => (document.hidden ? stop() : start());
window.addEventListener('ml:pause', onPause);
window.addEventListener('ml:resume', onResume);
document.addEventListener('visibilitychange', onVisibility);
// Playground: re-read knobs (renderer, scene and model are kept)
window.addEventListener('ml:knobs', build);
// ---------------------------------------------------------------- cleanup
window.MotionLab?.onCleanup(() => {
stop();
ctx?.revert();
introCtx.revert();
ro.disconnect();
window.removeEventListener('ml:knobs', build);
window.removeEventListener('ml:pause', onPause);
window.removeEventListener('ml:resume', onResume);
document.removeEventListener('visibilitychange', onVisibility);
controls.dispose();
scene.traverse((obj) => {
if (obj.geometry) obj.geometry.dispose();
if (obj.material) [].concat(obj.material).forEach((m) => m.dispose());
});
envTexture.dispose();
renderer.dispose();
renderer.domElement.remove();
});
```
### EFFECT (single HTML file): Interactive 3D Product (GLTF) Rotate + Orbit
Produce **one self-contained `index.html`** (inline `<style>` and `<script type="module">`, no build step) that implements **Interactive 3D Product (GLTF) Rotate + Orbit** exactly as specified below. Libraries load from jsDelivr:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Interactive 3D Product (GLTF) Rotate + Orbit</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..600;1,9..144,300..600&family=Inter+Tight:wght@300..700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{--bg:#0a0a0a;--surface:#121212;--line:rgba(255,255,255,.08);--text:#ededed;--muted:#8a8a8a;--accent:#d4ff3a;--font-display:"Fraunces",Georgia,serif;--font-sans:"Inter Tight",system-ui,sans-serif;--font-mono:"JetBrains Mono",ui-monospace,monospace}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:var(--font-sans);-webkit-font-smoothing:antialiased;overflow-x:hidden}
/* + the CSS requirements below */
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.186/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.186/examples/jsm/"}}</script>
</head>
<body>
<!-- markup from the HTML structure section -->
<script type="module">
// the JS from the animation spec (window.MotionLab is optional — keep the ?. guards or remove them)
</script>
</body>
</html>
```
The file must open directly from a local server (`npx serve .`) with zero console errors, work at 375px and 1440px, and honour `prefers-reduced-motion`.
---
### EFFECT: Interactive 3D Product (GLTF) Rotate + Orbit
**What it does (visual):** A full-viewport dark stage with a large thin serif headline top-left ("Maison Nocturne"), a short muted paragraph, and three pill-shaped colour swatches bottom-left. In the centre, a glossy perfume flask (a GLB) rises 0.4 units from below while scaling 0.9 → 1 and turning ~63° over 1.4–1.6s, and a soft contact shadow (opacity 0.3) fades in beneath it on an otherwise invisible floor. The flask then idles on a slow turntable (`autoRotateSpeed` 0.6; one revolution ≈ 100s at the default). The user can drag to orbit (the camera coasts to a stop with damping 0.06) and scroll/pinch to zoom between fixed limits; it is impossible to look from directly above or from under the floor. Two small accent-coloured (#d4ff3a) dots with hairline leader lines and mono labels ("Hand-turned brass cap", "Recycled glass, 120 ml") stick to the cap and to the side of the bottle that currently faces the camera, following it as it rotates. Clicking a swatch cross-fades the body's PBR colour to the new finish over 0.6s while the brass cap stays metallic. The whole frame is ACES tone-mapped at exposure 1.05. Reflections come from a studio-like environment (bright top panel, warm key strip, cool fill, a lime rim accent) generated at runtime, not from an HDR file.
**Tech stack:**
- Three.js r160+ (`three`, `three/addons/controls/OrbitControls.js`, `three/addons/loaders/GLTFLoader.js`) via importmap or `npm i three`.
- GSAP 3.13+ (core only) for the intro and the colour tween. CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`.
- One GLB (`/assets/interactive-3d-product/product.glb`). The demo's `make-glb.mjs` generates it in Node: two `LatheGeometry` meshes (Body: 128 segments; Cap: 96) written as a binary glTF 2.0 by hand — JSON chunk padded with `0x20` to 4 bytes, BIN chunk with `POSITION` (FLOAT VEC3, with `min`/`max`), `NORMAL` (FLOAT VEC3) and indices (UNSIGNED_SHORT SCALAR, bufferView target 34963) per mesh; materials `Body` (`baseColorFactor [0.48,0.12,0.18,1]`, `metallicFactor 0.1`, `roughnessFactor 0.25`) and `Cap` (`[0.83,0.68,0.38,1]`, metallic 1.0, roughness 0.32). Nodes are named `Body` and `Cap`. Any GLB with a mesh named `Body` works as a drop-in.
**HTML structure:**
```html
<section class="prod">
<div class="prod__stage">
<div class="prod__canvas"></div> <!-- renderer canvas is appended here -->
<p class="prod__loading">Loading model…</p>
<div class="prod__hotspot" data-hotspot="cap"><span class="prod__dot"></span><span class="prod__label">Hand-turned brass cap</span></div>
<div class="prod__hotspot" data-hotspot="body"><span class="prod__dot"></span><span class="prod__label">Recycled glass, 120 ml</span></div>
</div>
<header class="prod__header">
<p class="prod__eyebrow">WebGL · GLTF · OrbitControls</p>
<h1 class="prod__title">Maison Nocturne</h1>
<p class="prod__copy">Drag to orbit, scroll to zoom…</p>
</header>
<div class="prod__swatches" role="group" aria-label="Choose a finish">
<button class="prod__swatch is-active" data-color="#7a1e2e" aria-pressed="true"><span class="prod__chip" style="--c:#7a1e2e"></span>Oxblood</button>
<button class="prod__swatch" data-color="#9fb08f" aria-pressed="false"><span class="prod__chip" style="--c:#9fb08f"></span>Sage</button>
<button class="prod__swatch" data-color="#1a1d2b" aria-pressed="false"><span class="prod__chip" style="--c:#1a1d2b"></span>Ink</button>
</div>
</section>
```
**CSS requirements:**
- `.prod`: `position: relative; min-height: 100vh; display: grid; grid-template-rows: 1fr auto; overflow: hidden; background: var(--bg, #0a0a0a); color: var(--text, #ededed);`
- `.prod__stage`: `position: absolute; inset: 0;` `.prod__canvas`: `position: absolute; inset: 0; touch-action: none; cursor: grab;` (`:active { cursor: grabbing }`); the canvas itself `width: 100%; height: 100%; display: block`.
- `.prod__stage::before`: a decorative floor glow — `radial-gradient(50% 50% at 50% 50%, rgba(255,255,255,.07), transparent 70%)`, `width: min(80vw, 900px); aspect-ratio: 2/1;` centred at `left: 50%; top: 62%`.
- `.prod__header`: `position: relative; z-index: 2; pointer-events: none; padding: clamp(1.5rem, 4vw, 3.5rem) clamp(1.25rem, 6vw, 5rem) 0; max-width: 36rem;` Title: display serif, `font-weight: 300; font-size: clamp(2.6rem, 6.5vw, 6.5rem); line-height: .92; letter-spacing: -0.04em`. Eyebrow: mono 11px uppercase `letter-spacing: .14em`, muted.
- `.prod__swatches`: `position: relative; z-index: 2; display: flex; gap: .5rem; flex-wrap: wrap; align-self: end; padding: 0 clamp(1.25rem, 6vw, 5rem) clamp(1.5rem, 4vw, 3rem);`
- `.prod__swatch`: pill, `border: 1px solid rgba(255,255,255,.18); border-radius: 999px; padding: .55rem 1rem .55rem .6rem; font-size: .82rem;` `.is-active { border-color: var(--accent, #d4ff3a); color: var(--text) }`; `.prod__chip`: 18px circle filled with `var(--c)`.
- `.prod__hotspot`: `position: absolute; left: 0; top: 0; display: flex; align-items: center; gap: .6rem; opacity: 0; pointer-events: none; will-change: transform, opacity;` `.prod__dot`: 12px circle filled with `var(--accent, #d4ff3a)` and a 4px halo of the same colour at 22% alpha. `.prod__label`: mono 11px uppercase with a 1.6rem × 1px leader line drawn by `::before`. The body hotspot is mirrored (`flex-direction: row-reverse`, leader on the right).
**Animation spec (exact values):**
1. **Renderer**: `new THREE.WebGLRenderer({ antialias: true, alpha: true })`, `setPixelRatio(Math.min(devicePixelRatio, 2))`, transparent clear colour, `shadowMap.enabled = true`, `shadowMap.type = THREE.PCFSoftShadowMap`, `toneMapping = THREE.ACESFilmicToneMapping`, `toneMappingExposure = 1.05`.
2. **Camera**: `PerspectiveCamera(35, aspect, 0.1, 60)` at `(3.4, 2.3, 5.4)`.
3. **OrbitControls**: `target (0, 1.25, 0)`, `enableDamping: true`, `dampingFactor: 0.06`, `autoRotate: true`, `autoRotateSpeed: 0.6`, `enablePan: false`, `minDistance: 4`, `maxDistance: 9`, `minPolarAngle: 0.85`, `maxPolarAngle: 1.5`.
4. **Lights**: key `DirectionalLight(0xffffff, 2.4)` at `(4, 6.5, 3.5)`, `castShadow`, `shadow.mapSize 1024²`, ortho shadow camera `±3`, `near 1 / far 20`, `bias -0.0004`. Fill `DirectionalLight(0xdfe8ff, 0.7)` at `(-5, 3, 2.5)`. Rim `DirectionalLight(0xfff1d6, 1.8)` at `(-2.5, 4, -5)`.
5. **Shadow catcher**: `Mesh(PlaneGeometry(14, 14), ShadowMaterial({ opacity: 0 }))`, `rotation.x = -Math.PI/2`, `receiveShadow = true`; opacity tweens to `0.3` in the intro.
6. **Environment (procedural PMREM)**: build a throwaway `Scene` with a `BoxGeometry(12, 8, 12)` `MeshBasicMaterial({ color: 0x17181b, side: BackSide })` and five `MeshBasicMaterial` planes whose `color.multiplyScalar(intensity)` makes them HDR emitters: top `PlaneGeometry(4,4)` white ×4.0 at `(0, 3.95, 0)`; key strip `(1.6, 5)` `0xfff4e2` ×3.0 at `(5.95, 1, 2)`; fill `(3,3)` `0xbfd0ff` ×1.4 at `(-5.95, 1.5, 1)`; lime rim `(0.5, 5)` `0xd4ff3a` ×2.2 at `(-1.5, 1, -5.95)`; floor bounce `(6, 0.3)` white ×1.2 at `(0, -0.5, 5.95)`; every plane `lookAt(0,0,0)`. Then `scene.environment = new THREE.PMREMGenerator(renderer).fromScene(room, 0.04).texture`; dispose the generator and the room geometry/materials immediately.
7. **Load**: `new GLTFLoader().load(url, …)`; traverse: `castShadow = true`, `material.envMapIntensity = 1.1` (Cap `1.4`); keep a reference to the `Body` mesh's material; set its colour to the active swatch; remove the loading text.
8. **Intro timeline** (`defaults: { ease: 'power3.out' }`): `tl.from(model.position, { y: -0.4, duration: 1.4 }, 0)`; `tl.from(model.scale, { x: .9, y: .9, z: .9, duration: 1.4 }, 0)`; `tl.from(model.rotation, { y: -1.1, duration: 1.6 }, 0)`; `tl.to(catcherMaterial, { opacity: 0.3, duration: 1.2 }, 0.2)`; `tl.to(hotspots, { opacity: 1, duration: 0.6, stagger: 0.15, ease: 'power2.out' }, 0.9)`.
9. **Swatch click**: toggle `.is-active`/`aria-pressed`; `const c = new THREE.Color(hex); gsap.to(bodyMaterial.color, { r: c.r, g: c.g, b: c.b, duration: 0.6, ease: 'power2.inOut', overwrite: true })`.
10. **Hotspots (every frame)**: cap anchor = model-local `(0, 2.72, 0)` → `localToWorld`; body anchor = `(dir.x, 0.9, dir.z)` where `dir` is the horizontal unit vector from the orbit target to the camera (so the dot always sits on the side facing the viewer). `v.project(camera)`; `x = (v.x*0.5+0.5)*width`, `y = (-v.y*0.5+0.5)*height`; `el.style.transform = translate(x px, y px) translate(-6px,-6px)`; hide when `v.z >= 1`.
11. **Loop**: `const moved = controls.update(); if (moved || needsRender || controls.autoRotate) { updateHotspots(); renderer.render(scene, camera); }`.
**Interaction:** Pointer drag orbits (OrbitControls, damping 0.06 — 0.06 coasts ~0.5s after release); wheel / pinch zooms between distance 4 and 9; panning is disabled. Autorotation (0.6) continues while the user is not dragging (OrbitControls pauses it during interaction automatically). Swatch click = 0.6s colour cross-fade. Hover on swatches brightens the border over 0.25s.
**Responsive behavior:** Under 768px (`gsap.matchMedia('(max-width: 767px)')`): camera `fov` 35 → 44 and orbit target `y` 1.25 → 1.35 so the whole flask fits a portrait viewport; the header spans full width and the copy caps at 30ch. Touch orbits/pinch-zooms natively (`touch-action: none` on the canvas wrapper). Hotspot labels drop to 10px.
**Accessibility:** With `prefers-reduced-motion: reduce`: no intro animation, `autoRotate = false`, `enableDamping = false`, the colour change is instant (duration 0), and rendering is on demand (`controls.addEventListener('change', render)` plus one render after load and on resize) — no rAF loop. The canvas wrapper is `aria-hidden`; the swatch group has `role="group"` + `aria-label` and each button carries `aria-pressed`. Hotspots are decorative (`aria-hidden`) because their text is repeated in the page copy. Visible focus rings (`outline: 2px solid var(--accent); outline-offset: 3px`).
**Performance rules:** DPR capped at `Math.min(devicePixelRatio, 2)` (halved in the library's hover preview). One 1024² shadow map from the key light only. PMREM is generated once, before the model arrives, and the generator is disposed. Skip renders when nothing moved unless autorotating. Stop the rAF loop on `ml:pause` / `visibilitychange` and restart on `ml:resume`. On cleanup: stop the loop, `ctx.revert()`, `controls.dispose()`, traverse the scene disposing geometries and materials, dispose the environment texture, `renderer.dispose()`, remove the canvas. Use a `ResizeObserver` on the canvas wrapper rather than `window.resize`.
**Customization knobs:**
- `MODEL_URL` — any GLB; name the tintable mesh `Body`.
- `AUTO_ROTATE_SPEED` (0.6) and `DAMPING` (0.06).
- `COLOR_DURATION` (0.6) and the swatch `data-color` hex values.
- `SHADOW_OPACITY` (0.3) and the key light position (shadow direction).
- `toneMappingExposure` (1.05) — overall brightness of the tone-mapped frame.
- `--accent` (#d4ff3a) — hotspot dots and the active swatch outline.
- Environment panel colours/intensities — swap the lime strip for a brand colour.
- `minPolarAngle` / `maxPolarAngle` and `minDistance` / `maxDistance`.
**Acceptance criteria:**
- [ ] Reflections are visible on the body and cap before the intro finishes (environment built before load).
- [ ] The flask rises, scales and turns in over ~1.6s and then rotates slowly on its own; dragging stops autorotation and the camera coasts with damping.
- [ ] Zoom is clamped between 4 and 9 units; the camera never passes above the pole or below the floor.
- [ ] Clicking a swatch changes only the body colour, over 0.6s, with the active pill outlined in the accent colour (#d4ff3a).
- [ ] Both hotspots stay glued to their 3D anchors while orbiting; the body label stays on the side facing the camera.
- [ ] A soft shadow sits under the bottle on a transparent floor; the page background shows through.
- [ ] Reduced motion: no intro, no autorotation, instant colour change, still fully orbitable.
- [ ] No console errors; the GLB loads with HTTP 200.
---
**Reference implementation (use these exact values and class names):**
```html
<section class="prod" aria-label="Interactive 3D product viewer">
<div class="prod__stage">
<div class="prod__canvas" aria-hidden="true"></div>
<p class="prod__loading" aria-live="polite">Loading model…</p>
<div class="prod__hotspot" data-hotspot="cap" aria-hidden="true">
<span class="prod__dot"></span>
<span class="prod__label">Hand-turned brass cap</span>
</div>
<div class="prod__hotspot" data-hotspot="body" aria-hidden="true">
<span class="prod__dot"></span>
<span class="prod__label">Recycled glass, 120 ml</span>
</div>
</div>
<header class="prod__header">
<p class="prod__eyebrow">WebGL · GLTF · OrbitControls</p>
<h1 class="prod__title">Maison Nocturne</h1>
<p class="prod__copy">Drag to orbit, scroll to zoom. The flask idles on a slow turntable and settles with damping when you let go.</p>
</header>
<div class="prod__swatches" role="group" aria-label="Choose a finish">
<button class="prod__swatch is-active" type="button" data-color="#7a1e2e" aria-pressed="true"><span class="prod__chip" style="--c:#7a1e2e"></span>Oxblood</button>
<button class="prod__swatch" type="button" data-color="#9fb08f" aria-pressed="false"><span class="prod__chip" style="--c:#9fb08f"></span>Sage</button>
<button class="prod__swatch" type="button" data-color="#1a1d2b" aria-pressed="false"><span class="prod__chip" style="--c:#1a1d2b"></span>Ink</button>
</div>
</section>
```
```css
.prod { --accent: var(--knob-accent, var(--accent)); position: relative; min-height: 100vh; display: grid; grid-template-rows: 1fr auto; background: var(--bg); color: var(--text); overflow: hidden; }
.prod__stage { position: absolute; inset: 0; }
.prod__canvas { position: absolute; inset: 0; touch-action: none; cursor: grab; }
.prod__canvas:active { cursor: grabbing; }
.prod__canvas canvas { width: 100%; height: 100%; display: block; }
/* radial floor glow behind the shadow catcher */
.prod__stage::before {
content: ''; position: absolute; left: 50%; top: 62%; width: min(80vw, 900px); aspect-ratio: 2 / 1; transform: translate(-50%, -50%);
background: radial-gradient(50% 50% at 50% 50%, color-mix(in srgb, var(--text) 7%, transparent), transparent 70%);
pointer-events: none;
}
.prod__loading { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); font-family: var(--font-mono); font-size: 11px; letter-spacing: .14em; text-transform: uppercase; color: var(--muted); }
.prod__header { position: relative; z-index: 2; padding: clamp(1.5rem, 4vw, 3.5rem) clamp(1.25rem, 6vw, 5rem) 0; pointer-events: none; max-width: 36rem; }
.prod__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.1rem; }
.prod__title { font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30; font-size: clamp(2.6rem, 6.5vw, 6.5rem); line-height: .92; letter-spacing: -0.04em; }
.prod__copy { margin-top: 1.1rem; max-width: 34ch; color: var(--muted); font-size: clamp(.9rem, 1.1vw, 1.05rem); line-height: 1.5; }
.prod__swatches { position: relative; z-index: 2; display: flex; gap: .5rem; flex-wrap: wrap; padding: 0 clamp(1.25rem, 6vw, 5rem) clamp(1.5rem, 4vw, 3rem); align-self: end; }
.prod__swatch {
display: inline-flex; align-items: center; gap: .6rem; padding: .55rem 1rem .55rem .6rem; border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--text) 18%, transparent); font-size: .82rem; font-weight: 500; color: var(--muted);
transition: color .25s, border-color .25s, background .25s;
}
.prod__swatch:hover { color: var(--text); border-color: color-mix(in srgb, var(--text) 40%, transparent); }
.prod__swatch.is-active { color: var(--text); border-color: var(--accent); }
.prod__swatch:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.prod__chip { width: 18px; height: 18px; border-radius: 50%; background: var(--c); box-shadow: inset 0 0 0 1px rgba(255,255,255,.12); }
/* hotspots: positioned by JS from projected 3D points */
.prod__hotspot { position: absolute; left: 0; top: 0; display: flex; align-items: center; gap: .6rem; transform: translate(-6px, -6px); opacity: 0; pointer-events: none; will-change: transform, opacity; }
.prod__dot { width: 12px; height: 12px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 22%, transparent); flex: none; }
.prod__label { position: relative; padding-left: 2.2rem; font-family: var(--font-mono); font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--text); white-space: nowrap; }
.prod__label::before { content: ''; position: absolute; left: 0; top: 50%; width: 1.6rem; height: 1px; background: color-mix(in srgb, var(--text) 40%, transparent); }
.prod__hotspot[data-hotspot="body"] { flex-direction: row-reverse; }
.prod__hotspot[data-hotspot="body"] .prod__label { padding-left: 0; padding-right: 2.2rem; }
.prod__hotspot[data-hotspot="body"] .prod__label::before { left: auto; right: 0; }
@media (max-width: 767px) {
.prod__header { max-width: 100%; }
.prod__copy { max-width: 30ch; }
.prod__hotspot .prod__label { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.prod__swatch { transition: none; }
}
```
```js
// Interactive 3D product — GLTF flask, OrbitControls, 3-point studio light, procedural PMREM, swatches, hotspots.
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { autoRotateSpeed: 0.6, damping: 0.06, colorDuration: 0.6, shadowOpacity: 0.3, exposure: 1.05, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const MODEL_URL = '/assets/interactive-3d-product/product.glb';
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.prod');
const mount = section.querySelector('.prod__canvas');
const loadingEl = section.querySelector('.prod__loading');
const hotspotEls = { cap: section.querySelector('[data-hotspot="cap"]'), body: section.querySelector('[data-hotspot="body"]') };
const swatches = [...section.querySelectorAll('.prod__swatch')];
// ---------------------------------------------------------------- renderer (created once; knobs update its params)
const dprCap = window.MotionLab?.preview ? 1 : 2;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio, dprCap));
renderer.setClearColor(0x000000, 0);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = DEFAULTS.exposure;
mount.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(35, 1, 0.1, 60);
camera.position.set(3.4, 2.3, 5.4);
// ---------------------------------------------------------------- controls
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 1.25, 0);
controls.enableDamping = !reduced;
controls.dampingFactor = DEFAULTS.damping;
controls.autoRotate = !reduced;
controls.autoRotateSpeed = DEFAULTS.autoRotateSpeed;
controls.enablePan = false;
controls.minDistance = 4;
controls.maxDistance = 9;
controls.minPolarAngle = 0.85; // never look from directly above
controls.maxPolarAngle = 1.5; // never dip below the floor
controls.update();
// ---------------------------------------------------------------- lights (key / fill / rim)
const key = new THREE.DirectionalLight(0xffffff, 2.4);
key.position.set(4, 6.5, 3.5);
key.castShadow = true;
key.shadow.mapSize.set(1024, 1024);
key.shadow.camera.near = 1; key.shadow.camera.far = 20;
key.shadow.camera.left = -3; key.shadow.camera.right = 3; key.shadow.camera.top = 3; key.shadow.camera.bottom = -3;
key.shadow.bias = -0.0004;
scene.add(key);
const fill = new THREE.DirectionalLight(0xdfe8ff, 0.7);
fill.position.set(-5, 3, 2.5);
scene.add(fill);
const rim = new THREE.DirectionalLight(0xfff1d6, 1.8);
rim.position.set(-2.5, 4, -5);
scene.add(rim);
// Shadow catcher: invisible plane that only shows the shadow it receives.
const catcherMat = new THREE.ShadowMaterial({ opacity: reduced ? DEFAULTS.shadowOpacity : 0 });
const catcher = new THREE.Mesh(new THREE.PlaneGeometry(14, 14), catcherMat);
catcher.rotation.x = -Math.PI / 2;
catcher.receiveShadow = true;
scene.add(catcher);
// ---------------------------------------------------------------- procedural environment (PMREM from a lit room, no HDR file)
function buildEnvironment() {
const room = new THREE.Scene();
const disposables = [];
const add = (geo, color, intensity, pos, lookAt) => {
const mat = new THREE.MeshBasicMaterial({ color });
mat.color.multiplyScalar(intensity); // >1 = HDR emitter for the PMREM pass
const mesh = new THREE.Mesh(geo, mat);
mesh.position.copy(pos);
if (lookAt) mesh.lookAt(lookAt);
room.add(mesh);
disposables.push(geo, mat);
};
// dark walls
const wallsMat = new THREE.MeshBasicMaterial({ color: 0x17181b, side: THREE.BackSide });
const walls = new THREE.Mesh(new THREE.BoxGeometry(12, 8, 12), wallsMat);
room.add(walls); disposables.push(walls.geometry, wallsMat);
const c = new THREE.Vector3(0, 0, 0);
add(new THREE.PlaneGeometry(4, 4), 0xffffff, 4.0, new THREE.Vector3(0, 3.95, 0), c); // soft top light
add(new THREE.PlaneGeometry(1.6, 5), 0xfff4e2, 3.0, new THREE.Vector3(5.95, 1, 2), c); // warm key strip
add(new THREE.PlaneGeometry(3, 3), 0xbfd0ff, 1.4, new THREE.Vector3(-5.95, 1.5, 1), c); // cool fill
add(new THREE.PlaneGeometry(0.5, 5), 0xd4ff3a, 2.2, new THREE.Vector3(-1.5, 1, -5.95), c); // lime rim accent
add(new THREE.PlaneGeometry(6, 0.3), 0xffffff, 1.2, new THREE.Vector3(0, -0.5, 5.95), c); // thin floor-level bounce
const pmrem = new THREE.PMREMGenerator(renderer);
const envTex = pmrem.fromScene(room, 0.04).texture;
pmrem.dispose();
disposables.forEach((d) => d.dispose());
return envTex;
}
const envTexture = buildEnvironment();
scene.environment = envTexture;
// ---------------------------------------------------------------- model
let model = null;
let bodyMaterial = null;
let needsRender = true;
let introDone = reduced; // once the intro has landed, knob changes set the shadow opacity directly
const loader = new GLTFLoader();
loader.load(MODEL_URL, (gltf) => {
model = gltf.scene;
model.traverse((obj) => {
if (!obj.isMesh) return;
obj.castShadow = true;
obj.material.envMapIntensity = obj.name === 'Cap' ? 1.4 : 1.1;
if (obj.name === 'Body') bodyMaterial = obj.material;
});
scene.add(model);
loadingEl.remove();
const initial = swatches.find((b) => b.classList.contains('is-active'))?.dataset.color;
if (bodyMaterial && initial) bodyMaterial.color.set(initial);
introReveal();
needsRender = true;
}, undefined, (err) => {
loadingEl.textContent = 'The model failed to load.';
console.error('[interactive-3d-product] GLB load error', err);
});
// ---------------------------------------------------------------- intro (plays once after load, not on Playground rebuilds)
const introCtx = gsap.context(() => {});
function introReveal() {
if (reduced) {
gsap.set([hotspotEls.cap, hotspotEls.body], { opacity: 1 });
return;
}
introCtx.add(() => {
const tl = gsap.timeline({ defaults: { ease: 'power3.out' }, onComplete: () => { introDone = true; } });
tl.from(model.position, { y: -0.4, duration: 1.4 }, 0)
.from(model.scale, { x: 0.9, y: 0.9, z: 0.9, duration: 1.4 }, 0)
.from(model.rotation, { y: -1.1, duration: 1.6 }, 0)
.to(catcherMat, { opacity: () => K('shadowOpacity'), duration: 1.2 }, 0.2)
.to([hotspotEls.cap, hotspotEls.body], { opacity: 1, duration: 0.6, stagger: 0.15, ease: 'power2.out' }, 0.9);
});
}
// ---------------------------------------------------------------- build: read knobs → controls / renderer / shadow, (re)wire swatches + matchMedia
let ctx;
function build() {
ctx?.revert();
section.style.setProperty('--knob-accent', K('accent'));
controls.autoRotateSpeed = K('autoRotateSpeed');
controls.dampingFactor = K('damping');
renderer.toneMappingExposure = K('exposure');
if (introDone) catcherMat.opacity = K('shadowOpacity');
const colorDuration = reduced ? 0 : K('colorDuration');
ctx = gsap.context(() => {
const mm = gsap.matchMedia();
mm.add('(max-width: 767px)', () => {
camera.fov = 44; camera.updateProjectionMatrix();
controls.target.set(0, 1.35, 0);
needsRender = true;
return () => { camera.fov = 35; camera.updateProjectionMatrix(); controls.target.set(0, 1.25, 0); needsRender = true; };
});
const ac = new AbortController();
swatches.forEach((btn) => {
btn.addEventListener('click', () => {
swatches.forEach((b) => { b.classList.toggle('is-active', b === btn); b.setAttribute('aria-pressed', String(b === btn)); });
if (!bodyMaterial) return;
const target = new THREE.Color(btn.dataset.color);
gsap.to(bodyMaterial.color, { r: target.r, g: target.g, b: target.b, duration: colorDuration, ease: 'power2.inOut', overwrite: true, onUpdate: () => { needsRender = true; } });
}, { signal: ac.signal });
});
return () => ac.abort(); // remove the click handlers when the context is reverted (no stacked handlers on rebuild)
});
// a colour tween that was mid-flight when the context reverted rewound: land on the active swatch
const active = swatches.find((b) => b.classList.contains('is-active'))?.dataset.color;
if (bodyMaterial && active) bodyMaterial.color.set(active);
needsRender = true;
if (reduced) renderOnce();
}
// Project 3D anchor points to 2D and position the DOM labels.
const vCap = new THREE.Vector3();
const vBody = new THREE.Vector3();
const camDir = new THREE.Vector3();
function updateHotspots() {
if (!model) return;
const w = mount.clientWidth, h = mount.clientHeight;
// cap: fixed local point on top of the cap
vCap.set(0, 2.72, 0); model.localToWorld(vCap);
// body: point on the flask wall that currently faces the camera (height 0.9, radius 1)
camDir.subVectors(camera.position, controls.target); camDir.y = 0; camDir.normalize();
vBody.set(camDir.x * 1.0, 0.9, camDir.z * 1.0); model.localToWorld(vBody);
for (const [v, el] of [[vCap, hotspotEls.cap], [vBody, hotspotEls.body]]) {
v.project(camera);
const x = (v.x * 0.5 + 0.5) * w;
const y = (-v.y * 0.5 + 0.5) * h;
el.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) translate(-6px, -6px)`;
el.style.visibility = v.z < 1 ? 'visible' : 'hidden';
}
}
// ---------------------------------------------------------------- loop / pause / resize
let raf = 0, running = false;
function frame() {
raf = requestAnimationFrame(frame);
const moved = controls.update();
if (moved || needsRender || controls.autoRotate) {
updateHotspots();
renderer.render(scene, camera);
needsRender = false;
}
}
function start() { if (running || reduced) return; running = true; raf = requestAnimationFrame(frame); }
function stop() { running = false; cancelAnimationFrame(raf); }
function renderOnce() { controls.update(); updateHotspots(); renderer.render(scene, camera); }
function resize() {
const w = mount.clientWidth, h = mount.clientHeight;
if (!w || !h) return;
camera.aspect = w / h; camera.updateProjectionMatrix();
renderer.setSize(w, h, false);
needsRender = true;
if (reduced) renderOnce();
}
const ro = new ResizeObserver(resize);
ro.observe(mount);
build();
resize();
if (reduced) {
// No autoplay: render only when the user orbits or the model finishes loading.
controls.addEventListener('change', renderOnce);
loader.manager.onLoad = renderOnce;
} else {
start();
}
const onPause = () => stop();
const onResume = () => start();
const onVisibility = () => (document.hidden ? stop() : start());
window.addEventListener('ml:pause', onPause);
window.addEventListener('ml:resume', onResume);
document.addEventListener('visibilitychange', onVisibility);
// Playground: re-read knobs (renderer, scene and model are kept)
window.addEventListener('ml:knobs', build);
// ---------------------------------------------------------------- cleanup
window.MotionLab?.onCleanup(() => {
stop();
ctx?.revert();
introCtx.revert();
ro.disconnect();
window.removeEventListener('ml:knobs', build);
window.removeEventListener('ml:pause', onPause);
window.removeEventListener('ml:resume', onResume);
document.removeEventListener('visibilitychange', onVisibility);
controls.dispose();
scene.traverse((obj) => {
if (obj.geometry) obj.geometry.dispose();
if (obj.material) [].concat(obj.material).forEach((m) => m.dispose());
});
envTexture.dispose();
renderer.dispose();
renderer.domElement.remove();
});
```
### EFFECT (Framer & Webflow notes): Interactive 3D Product (GLTF) Rotate + Orbit
**Framer**
- Implement as a **Code Component** (Assets → Code → New component) when the effect owns its own markup (hero, gallery, section), or as a **Code Override** when it should animate existing Framer layers.
- Code Component: paste the React version (`"use client"` is not needed in Framer; keep `useGSAP` with a `scope` ref). Install `gsap` and `@gsap/react` from the package panel. Expose the customization knobs as `addPropertyControls` (numbers/colors/enums) so they are editable on the canvas.
- Code Override: `export function withInteractive3DProductGLTFRotateOrbit(Component) { return (props) => { const ref = useRef(null); useGSAP(() => { /* spec */ }, { scope: ref }); return <Component ref={ref} {...props} /> } }` and apply it to the layer in the right-hand panel.
- Framer already smooth-scrolls; do **not** add Lenis.
- Preview in the Framer preview window, not the canvas (GSAP runs at runtime only).
- Three.js in Framer: fine inside a Code Component; dispose the renderer in the effect cleanup.
**Webflow**
- Build the markup in the Designer with **exactly the class names from the HTML structure section** (prefix `prod`), so the script finds the elements.
- Styles: either recreate the CSS requirements as Webflow classes or paste the CSS in **Page settings → Custom code → Inside <head>** inside `<style>`.
- Scripts: **Page settings → Before </body>**:
```html
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script type="module">
// paste the JS from the animation spec; remove the optional window.MotionLab?.… lines
</script>
```
- Native alternative: parts that are pure show/hide, fade or move can be Webflow Interactions (IX2); keep the timeline sequencing in code — IX2 cannot do stagger/ease control at this precision.
- Custom code only runs on the **published** site or in Preview, never on the Designer canvas.
- Webflow's own smooth scroll is off by default; if you add Lenis, sync it: `lenis.on('scroll', ScrollTrigger.update)`.
---
### EFFECT: Interactive 3D Product (GLTF) Rotate + Orbit
**What it does (visual):** A full-viewport dark stage with a large thin serif headline top-left ("Maison Nocturne"), a short muted paragraph, and three pill-shaped colour swatches bottom-left. In the centre, a glossy perfume flask (a GLB) rises 0.4 units from below while scaling 0.9 → 1 and turning ~63° over 1.4–1.6s, and a soft contact shadow (opacity 0.3) fades in beneath it on an otherwise invisible floor. The flask then idles on a slow turntable (`autoRotateSpeed` 0.6; one revolution ≈ 100s at the default). The user can drag to orbit (the camera coasts to a stop with damping 0.06) and scroll/pinch to zoom between fixed limits; it is impossible to look from directly above or from under the floor. Two small accent-coloured (#d4ff3a) dots with hairline leader lines and mono labels ("Hand-turned brass cap", "Recycled glass, 120 ml") stick to the cap and to the side of the bottle that currently faces the camera, following it as it rotates. Clicking a swatch cross-fades the body's PBR colour to the new finish over 0.6s while the brass cap stays metallic. The whole frame is ACES tone-mapped at exposure 1.05. Reflections come from a studio-like environment (bright top panel, warm key strip, cool fill, a lime rim accent) generated at runtime, not from an HDR file.
**Tech stack:**
- Three.js r160+ (`three`, `three/addons/controls/OrbitControls.js`, `three/addons/loaders/GLTFLoader.js`) via importmap or `npm i three`.
- GSAP 3.13+ (core only) for the intro and the colour tween. CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`.
- One GLB (`/assets/interactive-3d-product/product.glb`). The demo's `make-glb.mjs` generates it in Node: two `LatheGeometry` meshes (Body: 128 segments; Cap: 96) written as a binary glTF 2.0 by hand — JSON chunk padded with `0x20` to 4 bytes, BIN chunk with `POSITION` (FLOAT VEC3, with `min`/`max`), `NORMAL` (FLOAT VEC3) and indices (UNSIGNED_SHORT SCALAR, bufferView target 34963) per mesh; materials `Body` (`baseColorFactor [0.48,0.12,0.18,1]`, `metallicFactor 0.1`, `roughnessFactor 0.25`) and `Cap` (`[0.83,0.68,0.38,1]`, metallic 1.0, roughness 0.32). Nodes are named `Body` and `Cap`. Any GLB with a mesh named `Body` works as a drop-in.
**HTML structure:**
```html
<section class="prod">
<div class="prod__stage">
<div class="prod__canvas"></div> <!-- renderer canvas is appended here -->
<p class="prod__loading">Loading model…</p>
<div class="prod__hotspot" data-hotspot="cap"><span class="prod__dot"></span><span class="prod__label">Hand-turned brass cap</span></div>
<div class="prod__hotspot" data-hotspot="body"><span class="prod__dot"></span><span class="prod__label">Recycled glass, 120 ml</span></div>
</div>
<header class="prod__header">
<p class="prod__eyebrow">WebGL · GLTF · OrbitControls</p>
<h1 class="prod__title">Maison Nocturne</h1>
<p class="prod__copy">Drag to orbit, scroll to zoom…</p>
</header>
<div class="prod__swatches" role="group" aria-label="Choose a finish">
<button class="prod__swatch is-active" data-color="#7a1e2e" aria-pressed="true"><span class="prod__chip" style="--c:#7a1e2e"></span>Oxblood</button>
<button class="prod__swatch" data-color="#9fb08f" aria-pressed="false"><span class="prod__chip" style="--c:#9fb08f"></span>Sage</button>
<button class="prod__swatch" data-color="#1a1d2b" aria-pressed="false"><span class="prod__chip" style="--c:#1a1d2b"></span>Ink</button>
</div>
</section>
```
**CSS requirements:**
- `.prod`: `position: relative; min-height: 100vh; display: grid; grid-template-rows: 1fr auto; overflow: hidden; background: var(--bg, #0a0a0a); color: var(--text, #ededed);`
- `.prod__stage`: `position: absolute; inset: 0;` `.prod__canvas`: `position: absolute; inset: 0; touch-action: none; cursor: grab;` (`:active { cursor: grabbing }`); the canvas itself `width: 100%; height: 100%; display: block`.
- `.prod__stage::before`: a decorative floor glow — `radial-gradient(50% 50% at 50% 50%, rgba(255,255,255,.07), transparent 70%)`, `width: min(80vw, 900px); aspect-ratio: 2/1;` centred at `left: 50%; top: 62%`.
- `.prod__header`: `position: relative; z-index: 2; pointer-events: none; padding: clamp(1.5rem, 4vw, 3.5rem) clamp(1.25rem, 6vw, 5rem) 0; max-width: 36rem;` Title: display serif, `font-weight: 300; font-size: clamp(2.6rem, 6.5vw, 6.5rem); line-height: .92; letter-spacing: -0.04em`. Eyebrow: mono 11px uppercase `letter-spacing: .14em`, muted.
- `.prod__swatches`: `position: relative; z-index: 2; display: flex; gap: .5rem; flex-wrap: wrap; align-self: end; padding: 0 clamp(1.25rem, 6vw, 5rem) clamp(1.5rem, 4vw, 3rem);`
- `.prod__swatch`: pill, `border: 1px solid rgba(255,255,255,.18); border-radius: 999px; padding: .55rem 1rem .55rem .6rem; font-size: .82rem;` `.is-active { border-color: var(--accent, #d4ff3a); color: var(--text) }`; `.prod__chip`: 18px circle filled with `var(--c)`.
- `.prod__hotspot`: `position: absolute; left: 0; top: 0; display: flex; align-items: center; gap: .6rem; opacity: 0; pointer-events: none; will-change: transform, opacity;` `.prod__dot`: 12px circle filled with `var(--accent, #d4ff3a)` and a 4px halo of the same colour at 22% alpha. `.prod__label`: mono 11px uppercase with a 1.6rem × 1px leader line drawn by `::before`. The body hotspot is mirrored (`flex-direction: row-reverse`, leader on the right).
**Animation spec (exact values):**
1. **Renderer**: `new THREE.WebGLRenderer({ antialias: true, alpha: true })`, `setPixelRatio(Math.min(devicePixelRatio, 2))`, transparent clear colour, `shadowMap.enabled = true`, `shadowMap.type = THREE.PCFSoftShadowMap`, `toneMapping = THREE.ACESFilmicToneMapping`, `toneMappingExposure = 1.05`.
2. **Camera**: `PerspectiveCamera(35, aspect, 0.1, 60)` at `(3.4, 2.3, 5.4)`.
3. **OrbitControls**: `target (0, 1.25, 0)`, `enableDamping: true`, `dampingFactor: 0.06`, `autoRotate: true`, `autoRotateSpeed: 0.6`, `enablePan: false`, `minDistance: 4`, `maxDistance: 9`, `minPolarAngle: 0.85`, `maxPolarAngle: 1.5`.
4. **Lights**: key `DirectionalLight(0xffffff, 2.4)` at `(4, 6.5, 3.5)`, `castShadow`, `shadow.mapSize 1024²`, ortho shadow camera `±3`, `near 1 / far 20`, `bias -0.0004`. Fill `DirectionalLight(0xdfe8ff, 0.7)` at `(-5, 3, 2.5)`. Rim `DirectionalLight(0xfff1d6, 1.8)` at `(-2.5, 4, -5)`.
5. **Shadow catcher**: `Mesh(PlaneGeometry(14, 14), ShadowMaterial({ opacity: 0 }))`, `rotation.x = -Math.PI/2`, `receiveShadow = true`; opacity tweens to `0.3` in the intro.
6. **Environment (procedural PMREM)**: build a throwaway `Scene` with a `BoxGeometry(12, 8, 12)` `MeshBasicMaterial({ color: 0x17181b, side: BackSide })` and five `MeshBasicMaterial` planes whose `color.multiplyScalar(intensity)` makes them HDR emitters: top `PlaneGeometry(4,4)` white ×4.0 at `(0, 3.95, 0)`; key strip `(1.6, 5)` `0xfff4e2` ×3.0 at `(5.95, 1, 2)`; fill `(3,3)` `0xbfd0ff` ×1.4 at `(-5.95, 1.5, 1)`; lime rim `(0.5, 5)` `0xd4ff3a` ×2.2 at `(-1.5, 1, -5.95)`; floor bounce `(6, 0.3)` white ×1.2 at `(0, -0.5, 5.95)`; every plane `lookAt(0,0,0)`. Then `scene.environment = new THREE.PMREMGenerator(renderer).fromScene(room, 0.04).texture`; dispose the generator and the room geometry/materials immediately.
7. **Load**: `new GLTFLoader().load(url, …)`; traverse: `castShadow = true`, `material.envMapIntensity = 1.1` (Cap `1.4`); keep a reference to the `Body` mesh's material; set its colour to the active swatch; remove the loading text.
8. **Intro timeline** (`defaults: { ease: 'power3.out' }`): `tl.from(model.position, { y: -0.4, duration: 1.4 }, 0)`; `tl.from(model.scale, { x: .9, y: .9, z: .9, duration: 1.4 }, 0)`; `tl.from(model.rotation, { y: -1.1, duration: 1.6 }, 0)`; `tl.to(catcherMaterial, { opacity: 0.3, duration: 1.2 }, 0.2)`; `tl.to(hotspots, { opacity: 1, duration: 0.6, stagger: 0.15, ease: 'power2.out' }, 0.9)`.
9. **Swatch click**: toggle `.is-active`/`aria-pressed`; `const c = new THREE.Color(hex); gsap.to(bodyMaterial.color, { r: c.r, g: c.g, b: c.b, duration: 0.6, ease: 'power2.inOut', overwrite: true })`.
10. **Hotspots (every frame)**: cap anchor = model-local `(0, 2.72, 0)` → `localToWorld`; body anchor = `(dir.x, 0.9, dir.z)` where `dir` is the horizontal unit vector from the orbit target to the camera (so the dot always sits on the side facing the viewer). `v.project(camera)`; `x = (v.x*0.5+0.5)*width`, `y = (-v.y*0.5+0.5)*height`; `el.style.transform = translate(x px, y px) translate(-6px,-6px)`; hide when `v.z >= 1`.
11. **Loop**: `const moved = controls.update(); if (moved || needsRender || controls.autoRotate) { updateHotspots(); renderer.render(scene, camera); }`.
**Interaction:** Pointer drag orbits (OrbitControls, damping 0.06 — 0.06 coasts ~0.5s after release); wheel / pinch zooms between distance 4 and 9; panning is disabled. Autorotation (0.6) continues while the user is not dragging (OrbitControls pauses it during interaction automatically). Swatch click = 0.6s colour cross-fade. Hover on swatches brightens the border over 0.25s.
**Responsive behavior:** Under 768px (`gsap.matchMedia('(max-width: 767px)')`): camera `fov` 35 → 44 and orbit target `y` 1.25 → 1.35 so the whole flask fits a portrait viewport; the header spans full width and the copy caps at 30ch. Touch orbits/pinch-zooms natively (`touch-action: none` on the canvas wrapper). Hotspot labels drop to 10px.
**Accessibility:** With `prefers-reduced-motion: reduce`: no intro animation, `autoRotate = false`, `enableDamping = false`, the colour change is instant (duration 0), and rendering is on demand (`controls.addEventListener('change', render)` plus one render after load and on resize) — no rAF loop. The canvas wrapper is `aria-hidden`; the swatch group has `role="group"` + `aria-label` and each button carries `aria-pressed`. Hotspots are decorative (`aria-hidden`) because their text is repeated in the page copy. Visible focus rings (`outline: 2px solid var(--accent); outline-offset: 3px`).
**Performance rules:** DPR capped at `Math.min(devicePixelRatio, 2)` (halved in the library's hover preview). One 1024² shadow map from the key light only. PMREM is generated once, before the model arrives, and the generator is disposed. Skip renders when nothing moved unless autorotating. Stop the rAF loop on `ml:pause` / `visibilitychange` and restart on `ml:resume`. On cleanup: stop the loop, `ctx.revert()`, `controls.dispose()`, traverse the scene disposing geometries and materials, dispose the environment texture, `renderer.dispose()`, remove the canvas. Use a `ResizeObserver` on the canvas wrapper rather than `window.resize`.
**Customization knobs:**
- `MODEL_URL` — any GLB; name the tintable mesh `Body`.
- `AUTO_ROTATE_SPEED` (0.6) and `DAMPING` (0.06).
- `COLOR_DURATION` (0.6) and the swatch `data-color` hex values.
- `SHADOW_OPACITY` (0.3) and the key light position (shadow direction).
- `toneMappingExposure` (1.05) — overall brightness of the tone-mapped frame.
- `--accent` (#d4ff3a) — hotspot dots and the active swatch outline.
- Environment panel colours/intensities — swap the lime strip for a brand colour.
- `minPolarAngle` / `maxPolarAngle` and `minDistance` / `maxDistance`.
**Acceptance criteria:**
- [ ] Reflections are visible on the body and cap before the intro finishes (environment built before load).
- [ ] The flask rises, scales and turns in over ~1.6s and then rotates slowly on its own; dragging stops autorotation and the camera coasts with damping.
- [ ] Zoom is clamped between 4 and 9 units; the camera never passes above the pole or below the floor.
- [ ] Clicking a swatch changes only the body colour, over 0.6s, with the active pill outlined in the accent colour (#d4ff3a).
- [ ] Both hotspots stay glued to their 3D anchors while orbiting; the body label stays on the side facing the camera.
- [ ] A soft shadow sits under the bottle on a transparent floor; the page background shows through.
- [ ] Reduced motion: no intro, no autorotation, instant colour change, still fully orbitable.
- [ ] No console errors; the GLB loads with HTTP 200.
Source
Full source code
<section class="prod" aria-label="Interactive 3D product viewer">
<div class="prod__stage">
<div class="prod__canvas" aria-hidden="true"></div>
<p class="prod__loading" aria-live="polite">Loading model…</p>
<div class="prod__hotspot" data-hotspot="cap" aria-hidden="true">
<span class="prod__dot"></span>
<span class="prod__label">Hand-turned brass cap</span>
</div>
<div class="prod__hotspot" data-hotspot="body" aria-hidden="true">
<span class="prod__dot"></span>
<span class="prod__label">Recycled glass, 120 ml</span>
</div>
</div>
<header class="prod__header">
<p class="prod__eyebrow">WebGL · GLTF · OrbitControls</p>
<h1 class="prod__title">Maison Nocturne</h1>
<p class="prod__copy">Drag to orbit, scroll to zoom. The flask idles on a slow turntable and settles with damping when you let go.</p>
</header>
<div class="prod__swatches" role="group" aria-label="Choose a finish">
<button class="prod__swatch is-active" type="button" data-color="#7a1e2e" aria-pressed="true"><span class="prod__chip" style="--c:#7a1e2e"></span>Oxblood</button>
<button class="prod__swatch" type="button" data-color="#9fb08f" aria-pressed="false"><span class="prod__chip" style="--c:#9fb08f"></span>Sage</button>
<button class="prod__swatch" type="button" data-color="#1a1d2b" aria-pressed="false"><span class="prod__chip" style="--c:#1a1d2b"></span>Ink</button>
</div>
</section>
.prod { --accent: var(--knob-accent, var(--accent)); position: relative; min-height: 100vh; display: grid; grid-template-rows: 1fr auto; background: var(--bg); color: var(--text); overflow: hidden; }
.prod__stage { position: absolute; inset: 0; }
.prod__canvas { position: absolute; inset: 0; touch-action: none; cursor: grab; }
.prod__canvas:active { cursor: grabbing; }
.prod__canvas canvas { width: 100%; height: 100%; display: block; }
/* radial floor glow behind the shadow catcher */
.prod__stage::before {
content: ''; position: absolute; left: 50%; top: 62%; width: min(80vw, 900px); aspect-ratio: 2 / 1; transform: translate(-50%, -50%);
background: radial-gradient(50% 50% at 50% 50%, color-mix(in srgb, var(--text) 7%, transparent), transparent 70%);
pointer-events: none;
}
.prod__loading { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); font-family: var(--font-mono); font-size: 11px; letter-spacing: .14em; text-transform: uppercase; color: var(--muted); }
.prod__header { position: relative; z-index: 2; padding: clamp(1.5rem, 4vw, 3.5rem) clamp(1.25rem, 6vw, 5rem) 0; pointer-events: none; max-width: 36rem; }
.prod__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.1rem; }
.prod__title { font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30; font-size: clamp(2.6rem, 6.5vw, 6.5rem); line-height: .92; letter-spacing: -0.04em; }
.prod__copy { margin-top: 1.1rem; max-width: 34ch; color: var(--muted); font-size: clamp(.9rem, 1.1vw, 1.05rem); line-height: 1.5; }
.prod__swatches { position: relative; z-index: 2; display: flex; gap: .5rem; flex-wrap: wrap; padding: 0 clamp(1.25rem, 6vw, 5rem) clamp(1.5rem, 4vw, 3rem); align-self: end; }
.prod__swatch {
display: inline-flex; align-items: center; gap: .6rem; padding: .55rem 1rem .55rem .6rem; border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--text) 18%, transparent); font-size: .82rem; font-weight: 500; color: var(--muted);
transition: color .25s, border-color .25s, background .25s;
}
.prod__swatch:hover { color: var(--text); border-color: color-mix(in srgb, var(--text) 40%, transparent); }
.prod__swatch.is-active { color: var(--text); border-color: var(--accent); }
.prod__swatch:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.prod__chip { width: 18px; height: 18px; border-radius: 50%; background: var(--c); box-shadow: inset 0 0 0 1px rgba(255,255,255,.12); }
/* hotspots: positioned by JS from projected 3D points */
.prod__hotspot { position: absolute; left: 0; top: 0; display: flex; align-items: center; gap: .6rem; transform: translate(-6px, -6px); opacity: 0; pointer-events: none; will-change: transform, opacity; }
.prod__dot { width: 12px; height: 12px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 22%, transparent); flex: none; }
.prod__label { position: relative; padding-left: 2.2rem; font-family: var(--font-mono); font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--text); white-space: nowrap; }
.prod__label::before { content: ''; position: absolute; left: 0; top: 50%; width: 1.6rem; height: 1px; background: color-mix(in srgb, var(--text) 40%, transparent); }
.prod__hotspot[data-hotspot="body"] { flex-direction: row-reverse; }
.prod__hotspot[data-hotspot="body"] .prod__label { padding-left: 0; padding-right: 2.2rem; }
.prod__hotspot[data-hotspot="body"] .prod__label::before { left: auto; right: 0; }
@media (max-width: 767px) {
.prod__header { max-width: 100%; }
.prod__copy { max-width: 30ch; }
.prod__hotspot .prod__label { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.prod__swatch { transition: none; }
}
// Interactive 3D product — GLTF flask, OrbitControls, 3-point studio light, procedural PMREM, swatches, hotspots.
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { autoRotateSpeed: 0.6, damping: 0.06, colorDuration: 0.6, shadowOpacity: 0.3, exposure: 1.05, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const MODEL_URL = '/assets/interactive-3d-product/product.glb';
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.prod');
const mount = section.querySelector('.prod__canvas');
const loadingEl = section.querySelector('.prod__loading');
const hotspotEls = { cap: section.querySelector('[data-hotspot="cap"]'), body: section.querySelector('[data-hotspot="body"]') };
const swatches = [...section.querySelectorAll('.prod__swatch')];
// ---------------------------------------------------------------- renderer (created once; knobs update its params)
const dprCap = window.MotionLab?.preview ? 1 : 2;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio, dprCap));
renderer.setClearColor(0x000000, 0);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = DEFAULTS.exposure;
mount.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(35, 1, 0.1, 60);
camera.position.set(3.4, 2.3, 5.4);
// ---------------------------------------------------------------- controls
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 1.25, 0);
controls.enableDamping = !reduced;
controls.dampingFactor = DEFAULTS.damping;
controls.autoRotate = !reduced;
controls.autoRotateSpeed = DEFAULTS.autoRotateSpeed;
controls.enablePan = false;
controls.minDistance = 4;
controls.maxDistance = 9;
controls.minPolarAngle = 0.85; // never look from directly above
controls.maxPolarAngle = 1.5; // never dip below the floor
controls.update();
// ---------------------------------------------------------------- lights (key / fill / rim)
const key = new THREE.DirectionalLight(0xffffff, 2.4);
key.position.set(4, 6.5, 3.5);
key.castShadow = true;
key.shadow.mapSize.set(1024, 1024);
key.shadow.camera.near = 1; key.shadow.camera.far = 20;
key.shadow.camera.left = -3; key.shadow.camera.right = 3; key.shadow.camera.top = 3; key.shadow.camera.bottom = -3;
key.shadow.bias = -0.0004;
scene.add(key);
const fill = new THREE.DirectionalLight(0xdfe8ff, 0.7);
fill.position.set(-5, 3, 2.5);
scene.add(fill);
const rim = new THREE.DirectionalLight(0xfff1d6, 1.8);
rim.position.set(-2.5, 4, -5);
scene.add(rim);
// Shadow catcher: invisible plane that only shows the shadow it receives.
const catcherMat = new THREE.ShadowMaterial({ opacity: reduced ? DEFAULTS.shadowOpacity : 0 });
const catcher = new THREE.Mesh(new THREE.PlaneGeometry(14, 14), catcherMat);
catcher.rotation.x = -Math.PI / 2;
catcher.receiveShadow = true;
scene.add(catcher);
// ---------------------------------------------------------------- procedural environment (PMREM from a lit room, no HDR file)
function buildEnvironment() {
const room = new THREE.Scene();
const disposables = [];
const add = (geo, color, intensity, pos, lookAt) => {
const mat = new THREE.MeshBasicMaterial({ color });
mat.color.multiplyScalar(intensity); // >1 = HDR emitter for the PMREM pass
const mesh = new THREE.Mesh(geo, mat);
mesh.position.copy(pos);
if (lookAt) mesh.lookAt(lookAt);
room.add(mesh);
disposables.push(geo, mat);
};
// dark walls
const wallsMat = new THREE.MeshBasicMaterial({ color: 0x17181b, side: THREE.BackSide });
const walls = new THREE.Mesh(new THREE.BoxGeometry(12, 8, 12), wallsMat);
room.add(walls); disposables.push(walls.geometry, wallsMat);
const c = new THREE.Vector3(0, 0, 0);
add(new THREE.PlaneGeometry(4, 4), 0xffffff, 4.0, new THREE.Vector3(0, 3.95, 0), c); // soft top light
add(new THREE.PlaneGeometry(1.6, 5), 0xfff4e2, 3.0, new THREE.Vector3(5.95, 1, 2), c); // warm key strip
add(new THREE.PlaneGeometry(3, 3), 0xbfd0ff, 1.4, new THREE.Vector3(-5.95, 1.5, 1), c); // cool fill
add(new THREE.PlaneGeometry(0.5, 5), 0xd4ff3a, 2.2, new THREE.Vector3(-1.5, 1, -5.95), c); // lime rim accent
add(new THREE.PlaneGeometry(6, 0.3), 0xffffff, 1.2, new THREE.Vector3(0, -0.5, 5.95), c); // thin floor-level bounce
const pmrem = new THREE.PMREMGenerator(renderer);
const envTex = pmrem.fromScene(room, 0.04).texture;
pmrem.dispose();
disposables.forEach((d) => d.dispose());
return envTex;
}
const envTexture = buildEnvironment();
scene.environment = envTexture;
// ---------------------------------------------------------------- model
let model = null;
let bodyMaterial = null;
let needsRender = true;
let introDone = reduced; // once the intro has landed, knob changes set the shadow opacity directly
const loader = new GLTFLoader();
loader.load(MODEL_URL, (gltf) => {
model = gltf.scene;
model.traverse((obj) => {
if (!obj.isMesh) return;
obj.castShadow = true;
obj.material.envMapIntensity = obj.name === 'Cap' ? 1.4 : 1.1;
if (obj.name === 'Body') bodyMaterial = obj.material;
});
scene.add(model);
loadingEl.remove();
const initial = swatches.find((b) => b.classList.contains('is-active'))?.dataset.color;
if (bodyMaterial && initial) bodyMaterial.color.set(initial);
introReveal();
needsRender = true;
}, undefined, (err) => {
loadingEl.textContent = 'The model failed to load.';
console.error('[interactive-3d-product] GLB load error', err);
});
// ---------------------------------------------------------------- intro (plays once after load, not on Playground rebuilds)
const introCtx = gsap.context(() => {});
function introReveal() {
if (reduced) {
gsap.set([hotspotEls.cap, hotspotEls.body], { opacity: 1 });
return;
}
introCtx.add(() => {
const tl = gsap.timeline({ defaults: { ease: 'power3.out' }, onComplete: () => { introDone = true; } });
tl.from(model.position, { y: -0.4, duration: 1.4 }, 0)
.from(model.scale, { x: 0.9, y: 0.9, z: 0.9, duration: 1.4 }, 0)
.from(model.rotation, { y: -1.1, duration: 1.6 }, 0)
.to(catcherMat, { opacity: () => K('shadowOpacity'), duration: 1.2 }, 0.2)
.to([hotspotEls.cap, hotspotEls.body], { opacity: 1, duration: 0.6, stagger: 0.15, ease: 'power2.out' }, 0.9);
});
}
// ---------------------------------------------------------------- build: read knobs → controls / renderer / shadow, (re)wire swatches + matchMedia
let ctx;
function build() {
ctx?.revert();
section.style.setProperty('--knob-accent', K('accent'));
controls.autoRotateSpeed = K('autoRotateSpeed');
controls.dampingFactor = K('damping');
renderer.toneMappingExposure = K('exposure');
if (introDone) catcherMat.opacity = K('shadowOpacity');
const colorDuration = reduced ? 0 : K('colorDuration');
ctx = gsap.context(() => {
const mm = gsap.matchMedia();
mm.add('(max-width: 767px)', () => {
camera.fov = 44; camera.updateProjectionMatrix();
controls.target.set(0, 1.35, 0);
needsRender = true;
return () => { camera.fov = 35; camera.updateProjectionMatrix(); controls.target.set(0, 1.25, 0); needsRender = true; };
});
const ac = new AbortController();
swatches.forEach((btn) => {
btn.addEventListener('click', () => {
swatches.forEach((b) => { b.classList.toggle('is-active', b === btn); b.setAttribute('aria-pressed', String(b === btn)); });
if (!bodyMaterial) return;
const target = new THREE.Color(btn.dataset.color);
gsap.to(bodyMaterial.color, { r: target.r, g: target.g, b: target.b, duration: colorDuration, ease: 'power2.inOut', overwrite: true, onUpdate: () => { needsRender = true; } });
}, { signal: ac.signal });
});
return () => ac.abort(); // remove the click handlers when the context is reverted (no stacked handlers on rebuild)
});
// a colour tween that was mid-flight when the context reverted rewound: land on the active swatch
const active = swatches.find((b) => b.classList.contains('is-active'))?.dataset.color;
if (bodyMaterial && active) bodyMaterial.color.set(active);
needsRender = true;
if (reduced) renderOnce();
}
// Project 3D anchor points to 2D and position the DOM labels.
const vCap = new THREE.Vector3();
const vBody = new THREE.Vector3();
const camDir = new THREE.Vector3();
function updateHotspots() {
if (!model) return;
const w = mount.clientWidth, h = mount.clientHeight;
// cap: fixed local point on top of the cap
vCap.set(0, 2.72, 0); model.localToWorld(vCap);
// body: point on the flask wall that currently faces the camera (height 0.9, radius 1)
camDir.subVectors(camera.position, controls.target); camDir.y = 0; camDir.normalize();
vBody.set(camDir.x * 1.0, 0.9, camDir.z * 1.0); model.localToWorld(vBody);
for (const [v, el] of [[vCap, hotspotEls.cap], [vBody, hotspotEls.body]]) {
v.project(camera);
const x = (v.x * 0.5 + 0.5) * w;
const y = (-v.y * 0.5 + 0.5) * h;
el.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) translate(-6px, -6px)`;
el.style.visibility = v.z < 1 ? 'visible' : 'hidden';
}
}
// ---------------------------------------------------------------- loop / pause / resize
let raf = 0, running = false;
function frame() {
raf = requestAnimationFrame(frame);
const moved = controls.update();
if (moved || needsRender || controls.autoRotate) {
updateHotspots();
renderer.render(scene, camera);
needsRender = false;
}
}
function start() { if (running || reduced) return; running = true; raf = requestAnimationFrame(frame); }
function stop() { running = false; cancelAnimationFrame(raf); }
function renderOnce() { controls.update(); updateHotspots(); renderer.render(scene, camera); }
function resize() {
const w = mount.clientWidth, h = mount.clientHeight;
if (!w || !h) return;
camera.aspect = w / h; camera.updateProjectionMatrix();
renderer.setSize(w, h, false);
needsRender = true;
if (reduced) renderOnce();
}
const ro = new ResizeObserver(resize);
ro.observe(mount);
build();
resize();
if (reduced) {
// No autoplay: render only when the user orbits or the model finishes loading.
controls.addEventListener('change', renderOnce);
loader.manager.onLoad = renderOnce;
} else {
start();
}
const onPause = () => stop();
const onResume = () => start();
const onVisibility = () => (document.hidden ? stop() : start());
window.addEventListener('ml:pause', onPause);
window.addEventListener('ml:resume', onResume);
document.addEventListener('visibilitychange', onVisibility);
// Playground: re-read knobs (renderer, scene and model are kept)
window.addEventListener('ml:knobs', build);
// ---------------------------------------------------------------- cleanup
window.MotionLab?.onCleanup(() => {
stop();
ctx?.revert();
introCtx.revert();
ro.disconnect();
window.removeEventListener('ml:knobs', build);
window.removeEventListener('ml:pause', onPause);
window.removeEventListener('ml:resume', onResume);
document.removeEventListener('visibilitychange', onVisibility);
controls.dispose();
scene.traverse((obj) => {
if (obj.geometry) obj.geometry.dispose();
if (obj.material) [].concat(obj.material).forEach((m) => m.dispose());
});
envTexture.dispose();
renderer.dispose();
renderer.domElement.remove();
});