GLB Spin-to-Reveal Stats Viewer — Free Three.js glTF Product Spec Snippet
GLB Spin-to-Reveal Stats Viewer · Media · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="gsr-wrap">
<canvas id="gsrCanvas"></canvas>
<div class="gsr-hint">Drag to spin and reveal stats · Ctrl/Cmd + scroll to zoom</div>
<div class="gsr-zoom">
<span class="gsr-zoom-label">+</span>
<input type="range" id="gsrZoom" class="gsr-zoom-slider" min="0" max="100" step="1" />
<span class="gsr-zoom-label">−</span>
</div>
<div class="gsr-stats" id="gsrStats">
<div class="gsr-stat" data-i="0"><div class="gsr-stat-label">Output</div><div class="gsr-stat-value">2 × drivers</div></div>
<div class="gsr-stat" data-i="1"><div class="gsr-stat-label">Battery</div><div class="gsr-stat-value">10 hrs</div></div>
<div class="gsr-stat" data-i="2"><div class="gsr-stat-label">Connectivity</div><div class="gsr-stat-value">Bluetooth 5</div></div>
<div class="gsr-stat" data-i="3"><div class="gsr-stat-label">Build</div><div class="gsr-stat-value">Aluminum shell</div></div>
</div>
</div>*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#0a0b12;color:#fff;padding:24px}
.gsr-wrap{position:relative;width:min(720px,94vw);height:min(520px,80vh);border-radius:20px;overflow:hidden;background:radial-gradient(60% 60% at 50% 42%,#181c28,#0a0b12);border:1px solid rgba(255,255,255,.08)}
#gsrCanvas{display:block;width:100%;height:100%;cursor:grab}
#gsrCanvas:active{cursor:grabbing}
.gsr-hint{position:absolute;top:18px;left:18px;font-size:11.5px;font-weight:600;color:#b7c0da;background:rgba(10,12,20,.55);padding:7px 14px;border-radius:999px;backdrop-filter:blur(6px);border:1px solid rgba(255,255,255,.08)}
.gsr-zoom{position:absolute;right:18px;top:18px;bottom:18px;width:32px;display:flex;flex-direction:column;align-items:center;gap:8px;background:rgba(10,12,20,.55);border:1px solid rgba(255,255,255,.1);border-radius:999px;padding:10px 0;backdrop-filter:blur(6px)}
.gsr-zoom-label{font-size:12px;font-weight:700;color:#b7c0da;line-height:1;user-select:none}
.gsr-zoom-slider{flex:1;width:6px;-webkit-appearance:slider-vertical;writing-mode:vertical-lr;direction:rtl;accent-color:#fb923c;cursor:pointer}
.gsr-stats{position:absolute;inset:0;pointer-events:none}
.gsr-stat{position:absolute;left:18px;bottom:18px;width:min(230px,55%);background:rgba(10,12,20,.7);border:1px solid rgba(255,255,255,.1);border-radius:14px;padding:14px 16px;backdrop-filter:blur(10px);opacity:0;transform:translateY(10px) scale(.97);transition:opacity .3s,transform .3s}
.gsr-stat.on{opacity:1;transform:translateY(0) scale(1)}
.gsr-stat-label{font-size:10.5px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#fdba74;margin-bottom:4px}
.gsr-stat-value{font-size:16px;font-weight:700;color:#fff}const canvas = document.getElementById('gsrCanvas');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 50);
camera.position.set(1.6, 0.9, 2.9);
// The only thing driving camera position is OrbitControls itself — the
// "spin to reveal" interaction below reads the camera's own live orbit
// angle rather than adding a second, competing rotation system.
const controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.target.set(0, 0.15, 0);
controls.minDistance = 1.5;
controls.maxDistance = 7;
controls.minPolarAngle = Math.PI / 3.2;
controls.maxPolarAngle = Math.PI / 1.8;
// OrbitControls' own wheel-zoom is turned off on purpose: left on, it calls
// preventDefault() on every wheel event over the canvas, which would block
// normal page scrolling around this card. Zoom is reimplemented below as an
// explicit, opt-in gesture (a slider, and Ctrl/Cmd + scroll) instead.
controls.enableZoom = false;
const zoomSlider = document.getElementById('gsrZoom');
function setZoomDistance(distance) {
const clamped = THREE.MathUtils.clamp(distance, controls.minDistance, controls.maxDistance);
const offset = camera.position.clone().sub(controls.target);
offset.setLength(clamped || 0.001);
camera.position.copy(controls.target).add(offset);
const t = (clamped - controls.minDistance) / (controls.maxDistance - controls.minDistance);
zoomSlider.value = String(Math.round((1 - t) * 100));
}
setZoomDistance(camera.position.distanceTo(controls.target));
zoomSlider.addEventListener('input', () => {
const t = 1 - Number(zoomSlider.value) / 100;
setZoomDistance(controls.minDistance + t * (controls.maxDistance - controls.minDistance));
});
canvas.addEventListener('wheel', (e) => {
if (!e.ctrlKey && !e.metaKey) return; // plain scroll always passes through to the page
e.preventDefault();
const current = camera.position.distanceTo(controls.target);
setZoomDistance(current + e.deltaY * 0.01);
}, { passive: false });
scene.add(new THREE.AmbientLight(0x445066, 0.7));
const key = new THREE.DirectionalLight(0xffffff, 1.9);
key.position.set(4, 6, 5);
scene.add(key);
const fill = new THREE.DirectionalLight(0xfb923c, 0.45);
fill.position.set(-5, 2, 3);
scene.add(fill);
const ground = new THREE.Mesh(
new THREE.CircleGeometry(3, 44),
new THREE.MeshStandardMaterial({ color: 0x14161f, roughness: 1 })
);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -0.75;
scene.add(ground);
// Each stat card is assigned an azimuthal angle around the product — the
// same angle space OrbitControls' own camera orbit already lives in — so
// "spinning to reveal" a stat means the camera's current azimuthal angle
// has to be genuinely close to that stat's own angle, not an arbitrary
// scroll or click trigger.
const STAT_ANGLES = [0, Math.PI / 2, Math.PI, -Math.PI / 2]; // front, right, back, left
const statEls = Array.from(document.querySelectorAll('.gsr-stat'));
const REVEAL_WINDOW = Math.PI / 3.2; // how close the camera must be to a stat's angle
function angleDiff(a, b) {
let d = (a - b) % (Math.PI * 2);
if (d > Math.PI) d -= Math.PI * 2;
if (d < -Math.PI) d += Math.PI * 2;
return Math.abs(d);
}
function updateRevealedStats() {
// OrbitControls exposes the camera's current azimuthal angle around its
// target directly, so no manual spherical-coordinate math is needed here
// beyond comparing that live angle against each stat's assigned angle.
const currentAzimuth = controls.getAzimuthalAngle();
statEls.forEach((el, i) => {
const close = angleDiff(currentAzimuth, STAT_ANGLES[i]) < REVEAL_WINDOW;
el.classList.toggle('on', close);
});
}
// Khronos' official sample-asset "BoomBox" — a real, freely-licensed .glb
// small, product-like, and detailed enough that spinning around it to
// reveal different real specs feels like a genuine product viewer.
const MODEL_URL = 'https://cdn.jsdelivr.net/gh/KhronosGroup/glTF-Sample-Assets@main/Models/BoomBox/glTF-Binary/BoomBox.glb';
const loader = new THREE.GLTFLoader();
loader.load(
MODEL_URL,
(gltf) => {
const model = gltf.scene;
// Auto-fit rather than a hardcoded scale factor: different .glb exports
// (and loader versions) can decode the same source asset at wildly
// different raw sizes, so measuring the loaded geometry and scaling it
// to a known target height is far more reliable than guessing a number.
const box = new THREE.Box3().setFromObject(model);
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z) || 1;
model.scale.setScalar(1.3 / maxDim);
const fitted = new THREE.Box3().setFromObject(model);
model.position.y += -0.75 - fitted.min.y;
scene.add(model);
},
undefined,
(err) => {
// Honest failure state: if the CDN model can't load, swap in a simple
// placeholder so the spin-to-reveal interaction still has something
// real to orbit around.
console.error('GLB failed to load, showing a placeholder instead:', err);
const placeholder = new THREE.Mesh(
new THREE.BoxGeometry(1.3, 0.6, 0.6),
new THREE.MeshStandardMaterial({ color: 0xfb923c, roughness: 0.4, metalness: 0.3 })
);
placeholder.position.y = -0.1;
scene.add(placeholder);
}
);
function resize() {
const w = canvas.clientWidth, h = canvas.clientHeight;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
function animate() {
requestAnimationFrame(animate);
controls.update();
updateRevealedStats();
renderer.render(scene, camera);
}
resize();
window.addEventListener('resize', resize);
animate();Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
GLB Spin-to-Reveal Stats Viewer — Specs Tied to the Camera's Real Orbit Angle

A common product-viewer pattern shows different information depending on which "side" of the object currently faces the camera. This snippet builds that honestly: each stat card is assigned a fixed azimuthal angle around the product, and on every frame the snippet compares that angle against OrbitControls' own current azimuthal angle — reading real orbit state, not a second, hand-rolled rotation tracker running in parallel.
One angle space, not two competing ones
OrbitControls already tracks the camera's azimuthal angle around its target internally to do its own job — and exposes it directly via controls.getAzimuthalAngle(). Rather than building a separate system to track "how far the user has spun the product," this snippet reuses that exact same built-in value. There is only ever one source of truth for the current viewing angle, which is also why the reveal state can never drift out of sync with what a visitor actually sees on screen.
Wrapped angle comparison, not naive subtraction
Azimuthal angles wrap around at plus or minus pi radians, so naively subtracting two angles can produce a value like 1.9 times pi for two angles that are actually very close together (just on opposite sides of the wrap point). angleDiff() normalizes the difference back into the [-pi, pi] range before taking its absolute value, so a stat assigned to the "back" of the product (angle pi) reveals correctly regardless of which direction the visitor spun to get there.
A reveal window, not an exact angle match
Each stat only needs the camera's current azimuth to fall within REVEAL_WINDOW radians of its assigned angle, not to match it exactly — a forgiving enough tolerance that a visitor doesn't have to hit one precise degree to trigger a reveal, while still being narrow enough that generally only one stat (occasionally two, near a boundary) is visible at once.
Camera constrained, not free-roaming
controls.minPolarAngle/maxPolarAngle are both set close to level, keeping the camera at roughly product-shot height throughout the whole interaction — the spin-to-reveal mechanic is specifically about *azimuthal* angle, so vertical wandering is intentionally limited rather than adding a dimension the stat system doesn't account for.
A named, honest fallback if the model fails
If the .glb can't load, a simple box placeholder takes the model's exact position, and the spin-to-reveal stat system keeps working exactly the same — the interaction only depends on the camera's own orbit state, never on anything specific to the loaded geometry.
Zoom is opt-in, not a hijacked scroll wheel
OrbitControls' built-in wheel-zoom is turned off, with zoom reimplemented as a slider plus Ctrl/Cmd + scroll, so a plain scroll over the card always scrolls the page.
Customizing it
Add more stat cards by extending STAT_ANGLES (evenly spacing them around the full circle), swap MODEL_URL for any other product-scaled .glb, or pair this with GLB product color configurator for a combined spec-and-color product page.
Step by step
How to Use
- 1Add all three CDN scriptsthree.min.js, GLTFLoader.js, and OrbitControls.js.
- 2Paste HTML, CSS, and JSThe BoomBox loads with its "Output" stat already revealed, facing forward.
- 3Drag on the canvasSpin the camera around the product; a different stat card fades in per side.
- 4Keep spinningBattery, Connectivity, and Build reveal as their assigned angle comes into view.
- 5Use the slider or Ctrl/Cmd + scroll to zoomPlain scroll always scrolls the page; zoom is a separate, opt-in gesture.
- 6Edit STAT_ANGLES and the HTML cardsReassign which angle reveals which spec, or add more stat cards.
Real-world uses
Common Use Cases
getAzimuthalAngle() from OrbitControls.Got questions?
Frequently Asked Questions
OrbitControls already tracks the camera's azimuthal angle around its target internally, and exposes it directly through controls.getAzimuthalAngle(). Rather than building a separate rotation-tracking system, this snippet reads that exact built-in value every frame and compares it against each stat card's own assigned angle — there's only ever one source of truth for the current viewing angle.
Azimuthal angles wrap around at positive and negative pi radians, so a naive subtraction between two angles that are actually close together (but on opposite sides of that wrap point) can produce a misleadingly large difference. The angleDiff() helper normalizes the raw difference back into the -pi to pi range before taking its absolute value, so a stat assigned to the back of the product reveals correctly regardless of which direction the visitor spun the camera to get there.
Requiring the camera's azimuth to match a stat's assigned angle exactly would make the interaction feel unresponsive and fiddly — a visitor would need to hit one precise degree. REVEAL_WINDOW gives each stat a forgiving tolerance range instead, wide enough to feel natural to spin into, while narrow enough that generally only one stat (occasionally two, near a boundary between them) is visible at any moment.
The loader's error callback logs the real failure and swaps in a simple box placeholder at the model's exact position. The spin-to-reveal stat system keeps working identically, since it only ever depends on the camera's own live orbit angle, never on anything specific to the loaded model's geometry.
Set up the renderer, scene, GLTFLoader call, OrbitControls, and STAT_ANGLES inside a mount effect, keeping controls and the stat element references in refs so the per-frame reveal check can reach current values. Call controls.dispose() and renderer.dispose() in the cleanup function to release the WebGL context and drag listeners on unmount.