Back to Blog

Build a 3D Product Viewer in Three.js (Orbit + Zoom)

Celest KimCelest Kim

Video: Build a 3D Product Viewer in Three.js (Orbit + Zoom) by CelesteAI

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Every modern product page wants the same trick: a 3D thing the user can grab, spin, and zoom into. A pair of sneakers. A watch. A coffee mug. The good news is the heavy lifting — turning a drag into a camera orbit, a scroll into a zoom — is already a class in Three.js called OrbitControls. The lesson is mostly building the scene around it.

In this episode we scaffold a TypeScript Vite project, build a ceramic mug and saucer out of three primitive shapes, light it so it casts a real shadow, and hand the camera to OrbitControls. About eighty lines of code, no models to download, no library other than three.

What you’ll learn

  • How to set up a Three.js scene with camera, lights, and a ground that catches shadows.
  • How to compose a recognizable “product” from CylinderGeometry, TorusGeometry, and a Group.
  • How to wire up OrbitControls for drag-to-rotate and scroll-to-zoom, with damping and sensible distance limits.
  • The render loop you’ll reuse for any future Three.js scene.

Scaffold the project

We use the Vite TypeScript template — same as the previous episodes:

npm create vite@latest product-viewer -- --template vanilla-ts --no-interactive
cd product-viewer
npm install
npm install three

That gives us a src/main.ts to write into and an index.html Vite will serve.

The HTML

Just a full-screen canvas:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>3D Product Viewer</title>
    <style>
      html, body { margin: 0; height: 100%; background: #f2eee7; }
      #view { position: fixed; inset: 0; display: block; }
    </style>
  </head>
  <body>
    <canvas id="view"></canvas>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

Renderer, camera, lights

Open src/main.ts. Grab the canvas, build a renderer with shadows turned on, and put a perspective camera in the scene:

import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";

const canvas = document.getElementById("view") as HTMLCanvasElement;
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.shadowMap.enabled = true;
renderer.setClearColor(0xf2eee7, 1);

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(35, innerWidth / innerHeight, 0.1, 100);
camera.position.set(3.4, 2.0, 3.6);

Two lights are enough: a directional “key” that casts the shadow, and a soft ambient that fills the dark side so we can still see the handle when the mug spins around.

scene.add(new THREE.AmbientLight(0xffffff, 0.45));
const key = new THREE.DirectionalLight(0xffffff, 1.2);
key.position.set(3, 5, 2);
key.castShadow = true;
scene.add(key);

The ground plane

A big flat plane sits under the mug to catch its shadow. Without receiveShadow = true nothing dark shows up — that’s the most common Three.js gotcha.

const ground = new THREE.Mesh(
  new THREE.PlaneGeometry(40, 40),
  new THREE.MeshStandardMaterial({ color: 0xe6e1d6, roughness: 1 })
);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -0.55;
ground.receiveShadow = true;
scene.add(ground);

The product — three primitives in a Group

The mug is just three shapes parented under a THREE.Group. Grouping means we could rotate, move, or scale the whole product with one line later.

  • Saucer: a flat CylinderGeometry.
  • Body: a taller CylinderGeometry with an open top, and a thinner cylinder inside for the dark “coffee.”
  • Handle: a half-TorusGeometry, rotated and pushed to the right side.
const ceramic = new THREE.MeshStandardMaterial({ color: 0xfafafa, roughness: 0.32, metalness: 0.04 });
const interior = new THREE.MeshStandardMaterial({ color: 0x2b1810, roughness: 0.85 });

const product = new THREE.Group();

const saucer = new THREE.Mesh(new THREE.CylinderGeometry(1.15, 1.15, 0.06, 96), ceramic);
saucer.position.y = -0.5;
saucer.castShadow = true;
saucer.receiveShadow = true;
product.add(saucer);

const body = new THREE.Mesh(new THREE.CylinderGeometry(0.55, 0.46, 1.05, 96, 1, true), ceramic);
body.position.y = 0.05;
body.castShadow = true;
product.add(body);

const innerWall = new THREE.Mesh(new THREE.CylinderGeometry(0.5, 0.42, 0.98, 96), interior);
innerWall.position.y = 0.04;
product.add(innerWall);

const handle = new THREE.Mesh(new THREE.TorusGeometry(0.28, 0.065, 20, 64, Math.PI), ceramic);
handle.rotation.set(0, 0, -Math.PI / 2);
handle.position.set(0.55, 0.1, 0);
handle.castShadow = true;
product.add(handle);

scene.add(product);

The body uses the six-arg CylinderGeometry — top radius, bottom radius, height, radial segments, height segments, and openEnded so the top is hollow. The slightly smaller inner cylinder fills the inside in dark coffee brown. The handle uses the optional last arg of TorusGeometry to draw only half a ring (Math.PI radians).

OrbitControls — drag and scroll, free

This is the whole point. Three lines of setup hand the user a perfectly tuned camera rig:

const controls = new OrbitControls(camera, canvas);
controls.target.set(0, 0.1, 0);
controls.enableDamping = true;
controls.minDistance = 2.4;
controls.maxDistance = 8;
controls.maxPolarAngle = Math.PI * 0.49;
  • target is the point the camera orbits around — the middle of the mug, slightly up.
  • enableDamping gives a smooth glide instead of stopping dead when the user lets go (you have to call controls.update() every frame for this to work).
  • minDistance / maxDistance clamp the zoom so the viewer can’t end up inside the mug or out in space.
  • maxPolarAngle stops the camera from tilting under the floor.

Resize + render loop

addEventListener("resize", () => {
  renderer.setSize(innerWidth, innerHeight);
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
});
renderer.setSize(innerWidth, innerHeight);

function tick() {
  controls.update();
  renderer.render(scene, camera);
  requestAnimationFrame(tick);
}
tick();

Run it:

npm run dev

Open the local URL Vite prints. Drag to spin the mug. Scroll to zoom in and out. That’s it.

Three takeaways

  1. OrbitControls is the demo. Drag-to-rotate and scroll-to-zoom are one import and four lines. Don’t write them yourself.
  2. Real shadows need both sides. The light has castShadow, the meshes have castShadow, and the ground has receiveShadow. Miss any one of those and the shadow disappears.
  3. A Group lets the product be one thing. Once everything is parented to product, rotating, scaling, or repositioning the whole mug is a single line on the group.

Swap the geometries and you have a viewer for anything else — a chair, a controller, a watch. The scene, the lights, and the controls don’t change. That’s why this scales.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.