:root { --bg:#04070f; --text:#eff4ff; --muted:#c2d0e7; --accent:#8de8ff; }
* { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--text); font-family:"Avenir Next","Segoe UI",sans-serif; }
#scene { position:fixed; inset:0; z-index:0; }
.topbar { position:fixed; inset:0 0 auto 0; z-index:30; display:flex; justify-content:space-between; align-items:center; padding:.75rem 1rem; background:rgba(0,0,0,.28); backdrop-filter:blur(8px); }
.topbar a { color:var(--accent); text-decoration:none; font-weight:700; }
button { border:1px solid rgba(255,255,255,.25); border-radius:999px; padding:.45rem .8rem; background:rgba(255,255,255,.07); color:var(--text); cursor:pointer; }
main { position:relative; z-index:10; }
.panel { min-height:90vh; width:min(920px,92%); margin:0 auto; display:grid; align-content:center; gap:.7rem; border-bottom:1px solid rgba(255,255,255,.12); }
.label { margin:0; color:var(--accent); letter-spacing:.1em; text-transform:uppercase; }
h1,h2,p { margin:0; }
h1 { font-size: clamp(2.1rem, 7vw, 4.3rem); }
p { color:var(--muted); max-width:62ch; }
if (!window.MotionPreference) {
const __mql = window.matchMedia("(prefers-reduced-motion: reduce)");
const __listeners = new Set();
const MotionPreference = {
prefersReducedMotion() {
return __mql.matches;
},
setOverride(value) {
const reduced = Boolean(value);
document.documentElement.classList.toggle("reduced-motion", reduced);
window.dispatchEvent(new CustomEvent("motion-preference", { detail: { reduced } }));
for (const listener of __listeners) {
try {
listener({ reduced, override: reduced, systemReduced: __mql.matches });
} catch {}
}
},
onChange(listener) {
__listeners.add(listener);
try {
listener({
reduced: __mql.matches,
override: null,
systemReduced: __mql.matches,
});
} catch {}
return () => __listeners.delete(listener);
},
getState() {
return { reduced: __mql.matches, override: null, systemReduced: __mql.matches };
},
};
window.MotionPreference = MotionPreference;
}
import * as THREE from "three";
const host = document.getElementById("scene");
const toggle = document.getElementById("toggleMotion");
let motionEnabled = !window.MotionPreference.prefersReducedMotion();
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x03050c);
scene.fog = new THREE.Fog(0x03050c, 10, 40);
const camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(0, 0.7, 7);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
host.appendChild(renderer.domElement);
const product = new THREE.Group();
scene.add(product);
const body = new THREE.Mesh(
new THREE.CylinderGeometry(1.2, 1.2, 2.4, 48),
new THREE.MeshStandardMaterial({ color: 0x1e2c47, metalness: 0.6, roughness: 0.25 })
);
product.add(body);
const ring = new THREE.Mesh(
new THREE.TorusGeometry(1.22, 0.08, 24, 80),
new THREE.MeshStandardMaterial({ color: 0x90e8ff, emissive: 0x478eb0, emissiveIntensity: 0.7, metalness: 0.4, roughness: 0.2 })
);
ring.rotation.x = Math.PI / 2;
ring.position.y = 0.7;
product.add(ring);
const cap = new THREE.Mesh(
new THREE.CylinderGeometry(1.15, 1.15, 0.35, 48),
new THREE.MeshStandardMaterial({ color: 0xbccae0, metalness: 0.85, roughness: 0.18 })
);
cap.position.y = 1.35;
product.add(cap);
scene.add(new THREE.AmbientLight(0x98b7ff, 0.45));
const key = new THREE.SpotLight(0x9edbff, 2.2, 60, 0.45, 0.35);
key.position.set(7, 8, 6);
scene.add(key);
const rim = new THREE.PointLight(0xd07cff, 0.8, 30);
rim.position.set(-6, 2, -5);
scene.add(rim);
function getScrollProgress() {
const max = Math.max(document.body.scrollHeight - window.innerHeight, 1);
return Math.min(1, Math.max(0, window.scrollY / max));
}
function setLabel() {
toggle.textContent = motionEnabled ? "Disable motion" : "Enable motion";
}
function animate(time) {
requestAnimationFrame(animate);
const t = time * 0.001;
const p = motionEnabled ? getScrollProgress() : 0;
const angle = -0.7 + p * 1.35;
const radius = 7 - p * 2.1;
camera.position.x += ((Math.cos(angle) * radius) - camera.position.x) * 0.08;
camera.position.z += ((Math.sin(angle) * radius) - camera.position.z) * 0.08;
camera.position.y += ((0.5 + p * 1.4) - camera.position.y) * 0.08;
camera.lookAt(0, 0.35, 0);
if (motionEnabled) {
product.rotation.y += 0.004;
ring.rotation.z = Math.sin(t * 1.2) * 0.2;
}
renderer.render(scene, camera);
}
function onResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
toggle.addEventListener("click", () => {
motionEnabled = !motionEnabled;
setLabel();
});
window.addEventListener("resize", onResize);
setLabel();
animate(0);
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Demo 12 - Product Spotlight</title>
<link rel="stylesheet" href="style.css" />
<script type="importmap">{"imports":{"gsap":"/vendor/gsap/index.js","gsap/ScrollTrigger":"/vendor/gsap/ScrollTrigger.js","gsap/SplitText":"/vendor/gsap/SplitText.js","gsap/Flip":"/vendor/gsap/Flip.js","gsap/ScrambleTextPlugin":"/vendor/gsap/ScrambleTextPlugin.js","gsap/TextPlugin":"/vendor/gsap/TextPlugin.js","gsap/all":"/vendor/gsap/all.js","gsap/":"/vendor/gsap/","lenis":"/vendor/lenis/dist/lenis.mjs","three":"/vendor/three/build/three.module.js","three/addons/":"/vendor/three/examples/jsm/"}}</script>
</head>
<body>
<header class="topbar">
<a href="../">Back to demos</a>
<button id="toggleMotion"></button>
</header>
<div id="scene"></div>
<main>
<section class="panel">
<p class="label">Demo 12</p>
<h1>3D Product Spotlight</h1>
<p>Scroll to move camera rails around the product.</p>
</section>
<section class="panel"><h2>Material Focus</h2><p>Observe light and rotation changes.</p></section>
<section class="panel"><h2>Hero Lock</h2><p>End at a composed front angle for CTA space.</p></section>
</main>
<script type="module" src="script.js"></script>
</body>
</html>