Web Animations Hard
3D Product Spotlight
Scroll-driven camera orbits a 3D product on rails, revealing feature cards at each angle.
Open in Lab
MCP
three.js gsap lenis scrolltrigger
Targets: JS HTML
Code
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg: #050508;
--text: #f0f4fb;
--muted: #8a95a8;
--accent: #86e8ff;
--border: #263249;
--neon-purple: #ae52ff;
}
body {
background: var(--bg);
color: var(--text);
font-family: "Inter", "SF Pro Display", system-ui, sans-serif;
}
/* Fixed Three.js canvas */
#canvas-container {
position: fixed;
inset: 0;
z-index: 0;
}
#canvas-container canvas {
display: block;
width: 100%;
height: 100%;
}
/* Scroll track โ tall container that drives the camera */
.scroll-track {
position: relative;
z-index: 2;
}
.rail-section {
min-height: 100vh;
display: flex;
align-items: center;
padding: 2rem 3rem;
pointer-events: none;
}
.rail-section:nth-child(odd) {
justify-content: flex-start;
}
.rail-section:nth-child(even) {
justify-content: flex-end;
}
/* Info cards */
.info-card {
pointer-events: auto;
max-width: 340px;
padding: 2rem;
background: rgba(12, 15, 25, 0.7);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(134, 232, 255, 0.1);
border-radius: 18px;
opacity: 0;
transform: translateY(24px);
transition: opacity 0.5s ease, transform 0.5s ease;
}
.info-card.visible {
opacity: 1;
transform: translateY(0);
}
.reduced-motion .info-card {
opacity: 1;
transform: none;
transition: none;
}
.card-label {
display: block;
font-size: 0.6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--accent);
margin-bottom: 0.75rem;
}
.info-card h2 {
font-size: 1.4rem;
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: 0.5rem;
}
.info-card p {
font-size: 0.85rem;
color: var(--muted);
line-height: 1.6;
}
/* Orbit progress bar (bottom) */
.orbit-progress {
position: fixed;
bottom: 3.5rem;
left: 50%;
transform: translateX(-50%);
width: 200px;
height: 3px;
background: rgba(134, 232, 255, 0.1);
border-radius: 2px;
z-index: 5;
overflow: hidden;
}
.orbit-progress-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, var(--accent), var(--neon-purple));
border-radius: 2px;
transition: width 0.1s linear;
}
@media (max-width: 640px) {
.rail-section {
padding: 1.5rem;
}
.info-card {
max-width: 280px;
padding: 1.5rem;
}
.rail-section:nth-child(odd),
.rail-section:nth-child(even) {
justify-content: center;
}
.orbit-progress {
width: 140px;
}
}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;
}
function prefersReducedMotion() {
return window.MotionPreference.prefersReducedMotion();
}
function initDemoShell() {
// No-op shim in imported standalone snippets.
}
import * as THREE from "three";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
gsap.registerPlugin(ScrollTrigger);
initDemoShell({
title: "3D Product Spotlight",
category: "3d",
tech: ["three.js", "gsap", "lenis", "scrolltrigger"],
});
let reduced = prefersReducedMotion();
if (reduced) document.documentElement.classList.add("reduced-motion");
// โโโ Lenis smooth scroll โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const lenis = new Lenis({ lerp: 0.1, smoothWheel: true });
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
// โโโ Three.js Setup โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const container = document.getElementById("canvas-container");
const scene = new THREE.Scene();
scene.background = new THREE.Color("#050508");
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
container.appendChild(renderer.domElement);
// โโโ Three-Point Lighting โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const ambientLight = new THREE.AmbientLight(0x111122, 0.5);
scene.add(ambientLight);
// Key light โ white, strong, casts shadows
const keyLight = new THREE.DirectionalLight(0xffffff, 2.5);
keyLight.position.set(5, 6, 5);
keyLight.castShadow = true;
keyLight.shadow.mapSize.set(1024, 1024);
keyLight.shadow.camera.near = 0.5;
keyLight.shadow.camera.far = 20;
scene.add(keyLight);
// Fill light โ cyan accent
const fillLight = new THREE.DirectionalLight(0x86e8ff, 1.0);
fillLight.position.set(-4, 3, -3);
scene.add(fillLight);
// Rim light โ purple accent
const rimLight = new THREE.DirectionalLight(0xae52ff, 1.8);
rimLight.position.set(0, -2, -6);
scene.add(rimLight);
// Accent point light that follows camera angle
const accentLight = new THREE.PointLight(0x86e8ff, 1.5, 15);
scene.add(accentLight);
// โโโ Product โ Torus Knot (PBR) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const productGeo = new THREE.TorusKnotGeometry(1.0, 0.35, 200, 32);
const productMat = new THREE.MeshPhysicalMaterial({
color: 0x86e8ff,
metalness: 0.95,
roughness: 0.08,
clearcoat: 1.0,
clearcoatRoughness: 0.03,
reflectivity: 1.0,
envMapIntensity: 1.0,
});
const product = new THREE.Mesh(productGeo, productMat);
product.position.set(0, 0.5, 0);
product.castShadow = true;
product.receiveShadow = true;
scene.add(product);
// โโโ Ground Plane โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const groundGeo = new THREE.CircleGeometry(8, 64);
const groundMat = new THREE.MeshStandardMaterial({
color: 0x0a0a14,
metalness: 0.8,
roughness: 0.4,
transparent: true,
opacity: 0.6,
});
const ground = new THREE.Mesh(groundGeo, groundMat);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -1.2;
ground.receiveShadow = true;
scene.add(ground);
// โโโ Subtle Fog โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
scene.fog = new THREE.FogExp2(0x050508, 0.04);
// โโโ Camera Rail โ Smooth Orbital Path โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const railPoints = [
new THREE.Vector3(5, 3, 5), // Front-right, elevated
new THREE.Vector3(0, 2.5, 6), // Front center
new THREE.Vector3(-5, 2, 4), // Left side
new THREE.Vector3(-5.5, 3.5, -2), // Left-back, higher
new THREE.Vector3(-3, 4.5, -5), // Behind, high angle
new THREE.Vector3(2, 4, -5.5), // Behind-right, high
new THREE.Vector3(5.5, 2, -3), // Right side
new THREE.Vector3(6, 1.8, 2), // Right-front, lower
new THREE.Vector3(5, 3, 5), // Return to start
];
const rail = new THREE.CatmullRomCurve3(railPoints, false, "catmullrom", 0.4);
const productCenter = new THREE.Vector3(0, 0.5, 0);
// Set initial camera position
const initialPos = rail.getPoint(0);
camera.position.copy(initialPos);
camera.lookAt(productCenter);
// โโโ Scroll โ Orbit Progress โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const scrollState = { progress: 0 };
const orbitFill = document.getElementById("orbit-fill");
gsap.to(scrollState, {
progress: 1,
ease: "none",
scrollTrigger: {
trigger: ".scroll-track",
start: "top top",
end: "bottom bottom",
scrub: 1.5,
onUpdate: () => {
if (orbitFill) {
orbitFill.style.width = `${scrollState.progress * 100}%`;
}
},
},
});
// โโโ Info Card Visibility โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const sections = document.querySelectorAll(".rail-section");
sections.forEach((section) => {
const card = section.querySelector(".info-card");
if (!card) return;
ScrollTrigger.create({
trigger: section,
start: "top 55%",
end: "bottom 45%",
onEnter: () => card.classList.add("visible"),
onLeave: () => card.classList.remove("visible"),
onEnterBack: () => card.classList.add("visible"),
onLeaveBack: () => card.classList.remove("visible"),
});
});
// โโโ Color Shifting โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const productColors = [
new THREE.Color(0x86e8ff), // Cyan
new THREE.Color(0xae52ff), // Purple
new THREE.Color(0xff40d6), // Pink
new THREE.Color(0xffcc66), // Gold
];
function getInterpolatedColor(progress) {
const segment = progress * (productColors.length - 1);
const index = Math.floor(segment);
const t = segment - index;
const c1 = productColors[Math.min(index, productColors.length - 1)];
const c2 = productColors[Math.min(index + 1, productColors.length - 1)];
return new THREE.Color().lerpColors(c1, c2, t);
}
// โโโ Animation Loop โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const elapsed = clock.getElapsedTime();
// Update camera position from scroll progress along the rail
const t = Math.max(0, Math.min(1, scrollState.progress));
const cameraPos = rail.getPoint(t);
camera.position.copy(cameraPos);
camera.lookAt(productCenter);
// Place accent light near camera but offset
accentLight.position.set(cameraPos.x * 0.6, cameraPos.y + 1, cameraPos.z * 0.6);
// Shift product color based on scroll progress
const color = getInterpolatedColor(t);
productMat.color.copy(color);
if (!reduced) {
// Slow product rotation on its own axis
product.rotation.y = elapsed * 0.15;
product.rotation.x = Math.sin(elapsed * 0.1) * 0.05;
}
renderer.render(scene, camera);
}
animate();
// โโโ Resize โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
window.addEventListener("resize", () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// โโโ Motion Preference โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
window.addEventListener("motion-preference", (e) => {
reduced = e.detail.reduced;
document.documentElement.classList.toggle("reduced-motion", reduced);
ScrollTrigger.refresh();
});
// โโโ Cleanup โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
window.addEventListener("beforeunload", () => {
productGeo.dispose();
productMat.dispose();
groundGeo.dispose();
groundMat.dispose();
renderer.dispose();
});<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3D Product Spotlight โ stealthisdesign</title>
<link rel="stylesheet" href="style.css">
<script type="importmap">{"imports":{"gsap":"https://esm.sh/gsap@3.13.0","gsap/ScrollTrigger":"https://esm.sh/gsap@3.13.0/ScrollTrigger","gsap/SplitText":"https://esm.sh/gsap@3.13.0/SplitText","gsap/Flip":"https://esm.sh/gsap@3.13.0/Flip","gsap/ScrambleTextPlugin":"https://esm.sh/gsap@3.13.0/ScrambleTextPlugin","gsap/TextPlugin":"https://esm.sh/gsap@3.13.0/TextPlugin","gsap/all":"https://esm.sh/gsap@3.13.0/all","gsap/":"https://esm.sh/gsap@3.13.0/","lenis":"https://esm.sh/lenis@1.1.13/dist/lenis.mjs","three":"https://esm.sh/three@0.171.0","three/addons/":"https://esm.sh/three@0.171.0/examples/jsm/"}}</script>
<style>html.lenis,
html.lenis body {
height: auto;
}
.lenis:not(.lenis-autoToggle).lenis-stopped {
overflow: clip;
}
.lenis [data-lenis-prevent],
.lenis [data-lenis-prevent-wheel],
.lenis [data-lenis-prevent-touch] {
overscroll-behavior: contain;
}
.lenis.lenis-smooth iframe {
pointer-events: none;
}
.lenis.lenis-autoToggle {
transition-property: overflow;
transition-duration: 1ms;
transition-behavior: allow-discrete;
}</style>
</head>
<body>
<!-- Three.js canvas (fixed behind everything) -->
<div id="canvas-container" aria-hidden="true"></div>
<!-- Scroll track drives the camera rail -->
<div class="scroll-track">
<section class="rail-section" data-section="0">
<div class="info-card card-left">
<span class="card-label">01 โ Design</span>
<h2>Precision Geometry</h2>
<p>Mathematical elegance expressed through parametric curves. Every vertex placed with purpose.</p>
</div>
</section>
<section class="rail-section" data-section="1">
<div class="info-card card-right">
<span class="card-label">02 โ Material</span>
<h2>Physical Rendering</h2>
<p>Metallic clearcoat finish with physically-based reflections. Light behaves as it does in reality.</p>
</div>
</section>
<section class="rail-section" data-section="2">
<div class="info-card card-left">
<span class="card-label">03 โ Lighting</span>
<h2>Cinematic Depth</h2>
<p>Three-point lighting sculpts form from shadow. Key, fill, and rim lights create dramatic dimension.</p>
</div>
</section>
<section class="rail-section" data-section="3">
<div class="info-card card-right">
<span class="card-label">04 โ Detail</span>
<h2>Every Angle</h2>
<p>Scroll reveals new reflections and color interplay. No two perspectives are the same.</p>
</div>
</section>
</div>
<!-- Orbit progress bar -->
<div class="orbit-progress">
<div class="orbit-progress-fill" id="orbit-fill"></div>
</div>
<script type="module" src="script.js"></script>
</body>
</html>3D Product Spotlight
Scroll-driven camera orbits a 3D product on rails, revealing feature cards at each angle.
Source
- Repository:
libs-genclaude - Original demo id:
22-product-spotlight
Notes
Scroll-driven camera orbits a 3D product on rails, revealing feature cards at each angle.