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.

Loading

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.

prompt.md · Interactive 3D Product (GLTF) Rotate + Orbit
### 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>