Building a Three.js Product Viewer: Why Lighting, Not Geometry, Makes 3D Look Real

Build a Three.js product viewer from scratch: three-point lighting, OrbitControls damping, metalness vs roughness, and the resize bug everyone hits.
Building a Three.js Product Viewer: Why Lighting, Not Geometry, Makes 3D Look Real

The first 3D object almost everyone renders on the web looks wrong, and it's never obvious why. The geometry is right, the code runs, the thing spins — and it still reads as a flat colored shape floating in a black void rather than an object you could pick up. The instinct is to blame the model: more polygons, a better mesh, maybe a texture.

It's almost never the model. It's the lighting, the material, and the fact that nothing is grounding the object in a space. The Three.js Product Viewer snippet is about seventy lines of JavaScript that render a metallic object you can drag to inspect, recolor with a swatch, and leave slowly turning — and roughly a quarter of those lines are doing nothing but arranging four lights and a disc on the floor.

Here it is live — drag to orbit it, scroll to zoom, tap a swatch:

Grab the code, or open the full editor with live HTML/CSS/JS panels: Three.js Product Viewer on FWD Tools. The snippet is plain HTML, CSS, and JavaScript with Three.js from a CDN, and the same editor has one-click export buttons for React, Vue, Angular, and React + Tailwind if you'd rather drop it into a component-based project.

In this post I'll build the whole thing A to Z: renderer and camera setup, the orbit controls and the one line people forget, the three-point lighting rig that does most of the visual work, why a disc on the floor changes everything, the material properties that make metal look like metal, and the resize function that is the most common bug in browser 3D. By the end you'll be able to make any object you load look like it was photographed rather than computed.

What the component actually is

Four things, in this order of importance:

  1. A lighting rig — four lights arranged the way a product photographer arranges them.
  2. A material with sensible metalness and roughness, so light behaves believably on the surface.
  3. A camera and controls tuned so the object reads as a product, not a scene.
  4. A mesh — the actual shape, which is genuinely the least interesting part and is a single line of code.

That ordering is the lesson. Swapping the demo's torus knot for a real product model changes one line; getting the other three right is what makes either of them look good.

Where you'd actually use this

  • E-commerce product configurators. Furniture, trainers, watches, phone cases — anywhere color and material choices matter and a photograph per variant is impractical. It pairs directly with a variant selector, where the swatch that recolors the mesh is the same swatch that sets the SKU.
  • Hardware and SaaS landing pages. A slowly rotating device above the fold reads as far more considered than a stock render, and it's a fraction of the file size of a video loop.
  • Documentation and training. Letting someone rotate the thing you're describing removes an entire paragraph of explanation.
  • Portfolio and case-study pages, where "you can pick it up" is the whole impression you're trying to make.
  • Any 3D asset you already own. If a GLTF file is sitting in a folder because nobody knew how to put it on the site, this is the seventy lines that puts it there.

The tech stack: Three.js and OrbitControls, both from a CDN

Two script tags, no build step:

  • Three.js core — the renderer, scene graph, cameras, lights, geometry and materials.
  • OrbitControls, an official addon — the drag-to-rotate, scroll-to-zoom behavior, which is not in the core library and is the single most common thing people reimplement badly by hand.

The markup is one canvas and a control panel:

<canvas id="productCanvas"></canvas>
<div class="pv-panel">
  <button id="pvAutoBtn" class="pv-btn active">Auto-rotate: On</button>
  <div class="pv-swatches">
    <button class="pv-swatch" style="--c:#f43f5e" data-color="#f43f5e"></button>
    <button class="pv-swatch" style="--c:#3b82f6" data-color="#3b82f6"></button>
    <!-- …two more -->
  </div>
</div>

One CSS detail is load-bearing before we reach any JavaScript:

html, body { width: 100%; height: 100%; overflow: hidden;
  background: radial-gradient(60% 60% at 50% 40%, #1c1f2b, #08090d); }
#productCanvas { display: block; width: 100%; height: 100%; cursor: grab; }
#productCanvas:active { cursor: grabbing; }

The canvas is sized by CSS, not by width and height attributes — which is what makes it responsive, and which the resize function in Step 8 has to cooperate with. The display: block removes the stray few pixels of inline-element descender space under a canvas. And the page background is a subtle radial gradient instead of flat black, so the object sits in something that reads as a studio backdrop instead of nothing at all. The grab/grabbing cursor is two lines and it's the only affordance telling people the object is draggable.

Building it, step by step

Step 1 — Renderer, and the pixel-ratio clamp that saves your frame rate

const canvas = document.getElementById('productCanvas');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

antialias: true smooths the jagged edges you'd otherwise get on every silhouette — on a product shot, where the outline is most of what the eye judges, it's worth the cost.

The second line is the one worth memorising. WebGL renders at whatever pixel ratio you give it, and a modern phone may report a devicePixelRatio of 3 or 4 — meaning nine to sixteen times as many pixels to shade as at ratio 1. Clamping to 2 keeps the render crisp on high-DPI screens while refusing to quadruple the work for a difference nobody can see. It is the single highest-value line of performance code in browser 3D, and it's one line.

Step 2 — Scene and camera, and why the field of view is 42

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 50);
camera.position.set(0, 0.6, 6.2);

Those four arguments are field of view, aspect ratio, and the near and far clipping planes. Three of them are quietly deliberate:

  • FOV 42, where most examples use 75. A wide field of view exaggerates perspective — the near edge of an object looms and the far edge shrinks away, which is great for immersive scenes and terrible for products, because it distorts proportions. A narrower FOV flattens perspective the way a portrait lens does, which is exactly the look of catalogue photography. If your object seems oddly stretched, this is usually why.
  • Aspect 1 is a placeholder — the real value is set in resize() before the first frame, so there's no point computing it twice.
  • Near 0.1, far 50 bracket the scene tightly. The ratio between them determines depth-buffer precision; setting near to 0.001 and far to 100000 "just in case" is what causes the flickering surfaces (z-fighting) that people mistake for a driver bug.

The camera sits slightly above center (y: 0.6) and back 6.2 units — a gentle downward angle, because looking at a product very slightly from above is how people actually hold objects, and it lets the floor disc from Step 5 read as a floor.

Step 3 — OrbitControls, damping, and the line everyone forgets

const controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.07;
controls.minDistance = 4;
controls.maxDistance = 10;
controls.autoRotate = true;
controls.autoRotateSpeed = 2.2;

Damping is what makes the drag feel physical. With it off, the object stops dead the instant you release the mouse — mechanically correct and immediately cheap-feeling. With it on, motion carries and settles, the way something on a turntable would. The factor of 0.07 is a light touch: lower drifts longer, higher stops sooner.

Damping has a catch, and it's the classic Three.js gotcha: it only works if you call controls.update() every frame. Enable damping without adding that call to the render loop and the object simply won't move at all, which sends a lot of people hunting through their event handlers for a bug that's in Step 9.

minDistance and maxDistance are the guardrails that separate a product viewer from a tech demo. Without them, one scroll gesture puts the camera inside the object's geometry or leaves it as a speck on the horizon, and there's no obvious way back. Bounding zoom to a sensible range means every state your viewer can reach is one you designed.

Step 4 — The lighting rig: the part that actually matters

This is the heart of the snippet. It's a photographer's three-point setup, translated into WebGL:

scene.add(new THREE.AmbientLight(0x334155, 0.5));

const key = new THREE.DirectionalLight(0xffffff, 1.4);
key.position.set(4, 5, 5);
scene.add(key);

const fill = new THREE.DirectionalLight(0x93c5fd, 0.5);
fill.position.set(-5, 1, 3);
scene.add(fill);

const rim = new THREE.PointLight(0xffffff, 1.2, 20);
rim.position.set(-2, 2, -5);
scene.add(rim);

Four lights, four distinct jobs:

  1. Ambient — a dim, cool, directionless wash at intensity 0.5. Its only job is to stop unlit areas going pure black. Push it higher and you flatten everything, because light with no direction carries no shape information.
  2. Key — a bright white directional light, up and to the right at intensity 1.4. This is the sun: it establishes where the highlight sits and which way the shadows fall. If you only ever add one light, add this one.
  3. Fill — a dimmer light (0.5) on the opposite side, tinted cool blue. It lifts the shadow side so it reads as shadow instead of a hole. The color is doing real work: the key stays neutral white while the fill is tinted blue, and that temperature difference between the two sides is what photographers use to create depth — a fill in the same white as the key looks noticeably flatter side by side.
  4. Rim — a point light behind the object at (−2, 2, −5). You never see it directly; what you see is a bright edge along the silhouette. That edge is what separates the object from the dark background and is the difference between "3D object" and "shape on a screen."

The reason to learn this rig rather than tweak lights until something looks acceptable is that it's diagnostic. Object looks flat? The key and fill are too close in intensity. Shadows look like black holes? Fill or ambient too low. Object seems pasted onto the background? No rim light. Everything looks washed out? Ambient too high. Four named lights, four named failure modes.

Step 5 — A disc on the floor

const ground = new THREE.Mesh(
  new THREE.CircleGeometry(4, 48),
  new THREE.MeshStandardMaterial({ color: 0x14161f, roughness: 1 })
);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -1.6;
scene.add(ground);

A dark circle, rotated flat and placed below the object. It has no texture, casts nothing, and is barely visible — and removing it makes the whole scene worse.

What it provides is a horizon. It catches a little light, giving the eye a surface for the object to sit above, and it turns "floating in a void" into "on a surface, in a space." roughness: 1 makes it completely matte so it never competes with the product for attention, and the -Math.PI / 2 rotation is the standard trick for making a circle (which is created standing up in the XY plane) lie flat on the ground.

It's the cheapest realism in the file: seven lines, no assets, and the object stops levitating.

Step 6 — The product, and what metalness and roughness actually mean

const geometry = new THREE.TorusKnotGeometry(1, 0.34, 180, 24);
const material = new THREE.MeshStandardMaterial({ color: 0x3b82f6, metalness: 0.55, roughness: 0.28 });
const product = new THREE.Mesh(geometry, material);
scene.add(product);

A mesh is geometry plus material. The geometry here is a torus knot because it's a shape with continuously curving surfaces, which is what shows off a lighting rig. In your project, this line becomes a loaded model and nothing else changes.

MeshStandardMaterial is the physically-based material, and its two key properties are worth understanding rather than nudging:

  • metalness (0.55) — how metallic the surface is. At 0 it's a dielectric (plastic, wood, fabric) and the highlight is white; at 1 it's a pure metal, which takes its color from what it reflects, not from a base color. Physically, real materials are almost always 0 or 1 — but a mid value like 0.55 is a common practical cheat for a viewer with no environment map, giving a rich, slightly metallic finish that still shows its base color clearly.
  • roughness (0.28) — how scattered the reflections are. Low is glossy and mirror-like, high is matte. This single number does more for perceived material than the color does: 0.1 reads as polished chrome, 0.9 as chalk, and the same geometry can be either.

If you take one adjustment away from this section: change roughness before you change anything else. It's the fastest way to make a render look like a different object entirely.

Step 7 — Swatches, in one line each

document.querySelectorAll('.pv-swatch').forEach(btn => {
  btn.addEventListener('click', () => material.color.set(btn.dataset.color));
});

Every button carries its color on data-color, and clicking one calls material.color.set(). There's no re-render call, no scene rebuild, no reload — the render loop is already running, so the next frame simply picks up the new color.

This works because the material is a shared, live object. Every mesh using it updates at once, which is exactly what you want for a color configurator and exactly what will surprise you the first time you accidentally share a material between two objects you meant to style differently. If a real configurator needs per-variant finishes — matte black versus polished chrome — set roughness and metalness alongside the color in the same handler, since a color swap alone can't express a material change.

The auto-rotate toggle is the same shape — flip a boolean the controls already read every frame:

autoBtn.addEventListener('click', () => {
  controls.autoRotate = !controls.autoRotate;
  autoBtn.classList.toggle('active', controls.autoRotate);
  autoBtn.textContent = 'Auto-rotate: ' + (controls.autoRotate ? 'On' : 'Off');
});

Step 8 — Resize: the most common bug in browser 3D

function resize() {
  const w = canvas.clientWidth, h = canvas.clientHeight;
  renderer.setSize(w, h, false);
  camera.aspect = w / h;
  camera.updateProjectionMatrix();
}

resize();
window.addEventListener('resize', resize);

Three lines, and each one fixes a specific symptom.

setSize(w, h, false). That third argument is updateStyle, and passing false tells Three.js to resize the drawing buffer without writing inline width and height styles onto the canvas. Because our canvas is sized by CSS at 100% of its container, letting Three.js write its own inline styles would override that and break the responsive layout — usually appearing as a canvas that grows on every resize and never shrinks back. This is the most reliably confusing bug in beginner Three.js, and the fix is one boolean.

camera.aspect = w / h. Skip it and everything stretches or squashes the moment the viewport isn't square — the aspect passed to the constructor was only ever a placeholder.

camera.updateProjectionMatrix(). Camera properties don't take effect on their own; the projection matrix is computed once and cached. Change aspect, fov or the clipping planes without this call and nothing happens, which looks exactly like your resize handler never fired.

Calling resize() once immediately is what sets the real aspect ratio before the first frame renders, so the placeholder value from Step 2 never actually reaches the screen.

Step 9 — The render loop

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

Three lines, and the whole scene comes alive. requestAnimationFrame schedules the next frame at the display's refresh rate and — importantly — pauses entirely when the tab is in the background, so an idle viewer costs a user nothing.

controls.update() is the line promised back in Step 3. It advances the damping and the auto-rotation, which is why both of those features live in the loop rather than in an event handler. It's also the reason a "broken" OrbitControls is nearly always a missing line here rather than a configuration problem.

Then a single render() call draws the scene from the camera. Everything else in this file — the lights, the material, the disc — is setup that this one line makes visible.

Customizing it for your own project

  • Load a real model. Add GLTFLoader, load a .glb, and add the returned scene instead of the torus knot. Center it and scale it to roughly the same size the knot occupied, so the camera, lights and floor disc still frame it correctly — that framing is the work you've already done.
  • Add an environment map. A single HDR image assigned to scene.environment gives every reflective surface something real to reflect, and is the biggest single jump in realism after the lighting rig. It also lets you push metalness to a physically honest 1.
  • Turn on shadows. Enable renderer.shadowMap, set the key light to cast and the disc to receive. It grounds the object further, at a real performance cost — measure before shipping it to mobile.
  • Constrain the orbit. minPolarAngle and maxPolarAngle stop shoppers looking at your product from directly underneath, which is rarely its best angle.
  • Match your brand. The background gradient, the swatch colors and the light tints are all one edit each. Keep a temperature difference between the key and the fill even when both come from brand colors — that contrast is what creates depth.
  • Respect reduced motion. Start with controls.autoRotate = false when prefers-reduced-motion is set. The viewer still works; it just waits to be driven.

The three things deliberately left out

Model loading. The snippet is self-contained on purpose, and a GLTF file would make it depend on an asset that could 404. Everything else here is exactly what a real model needs around it.

Shadow maps and an environment map. Both are genuine upgrades and both add cost and setup — shadows need light frustum tuning, environments need an HDR file. Leaving them out keeps the lighting lesson legible: everything you see in the demo is coming from four lights and one material.

Cleanup. There's no dispose() path, because the page lives as long as the scene does. In an app that mounts and unmounts the viewer, that changes — see the next section, because leaked WebGL contexts are one of the few ways to genuinely break a browser tab.

Using it in React, Vue, or Angular

The editor exports all four, and the shape of the port is the same everywhere: creation goes in a mount effect, and — the part that matters — teardown goes in its cleanup.

In React, build the scene inside a useEffect with an empty dependency array and a ref to the canvas. The cleanup function must cancel the requestAnimationFrame handle, remove the resize listener, call controls.dispose(), dispose the geometries and materials you created, and call renderer.dispose(). Skipping this in a single-page app leaks a WebGL context per mount, and browsers cap the number of live contexts — after a dozen navigations the canvas simply goes blank, which is a memorably confusing bug to debug. Keep the Three.js objects in refs, not state: they change every frame and none of those changes should trigger a render.

Vue's onMounted and onBeforeUnmount map one-to-one, and Angular's ngOnInit/ngOnDestroy do the same — with the added note that the render loop should run outside Angular's zone, or change detection will fire sixty times a second for no reason. If you're building anything substantial in React specifically, react-three-fiber is worth the dependency: it expresses this scene as components and handles the teardown for you. The concepts in this post transfer directly — the same lights, the same material, the same camera.

Build, understand, optimize, and extend it with AI

This snippet rewards experiment more than most, because every parameter is visible in the result. Paste the HTML, CSS and JS into an AI assistant like Claude and start by having it explain the lighting rig back to you — key, fill, rim, ambient — then ask it to predict what happens if you delete the rim light, and separately if you raise the ambient to 3. Try both in a copy: without the rim light the object loses its edge and sinks into the background, and with the ambient at 3 it goes flat and chalky as directionless light drowns the shading. Seeing those two failures is worth more than any amount of reading about lighting. Then ask what metalness: 0.55 means physically and why real materials are usually 0 or 1, and have it sweep roughness from 0.05 to 0.95 so you can see the material change identity while the geometry stays fixed. For the machinery, ask it to explain why setSize's third argument is false here and what breaks if it's true, and why camera.updateProjectionMatrix() is required after changing aspect. Then extend, in order of value: swap the torus knot for a GLTF model with GLTFLoader, add an HDR environment map and push metalness to 1, enable shadow maps on the key light and the ground disc, constrain the polar angle so the product can't be viewed from below, and add a full dispose() teardown for a single-page app. Treat the code less like a finished artifact and more like a starting point for a conversation.

Prompt to recreate it

Copy this into your AI assistant of choice to build the component from scratch, or as a jumping-off point for your own variant:

Build a Three.js product viewer in plain HTML, CSS, and JavaScript, loading Three.js and OrbitControls from a CDN. It should look like studio product photography, not a spinning demo cube.

Requirements:
- A full-viewport canvas sized entirely by CSS (100% width and height, display: block), on a page with a subtle radial-gradient dark background, plus grab/grabbing cursors so the object reads as draggable.
- A WebGLRenderer with antialiasing and a pixel ratio clamped to Math.min(devicePixelRatio, 2), and a PerspectiveCamera with a NARROW field of view (around 42, not 75) so the product isn't distorted by exaggerated perspective, positioned slightly above center and pulled back.
- OrbitControls with damping enabled (factor around 0.07) and minDistance/maxDistance limits so the camera can never end up inside the object or lost in the distance. Enable autoRotate, and add a button that toggles it and updates its own label.
- Classic three-point studio lighting, commented so the roles are clear: a dim cool AmbientLight so shadows never go pure black, a bright white DirectionalLight key light up and to one side, a dimmer cool-tinted DirectionalLight fill on the opposite side, and a PointLight rim light BEHIND the object to catch its silhouette edge and separate it from the background.
- A large, matte, dark CircleGeometry disc rotated flat and placed below the object, purely to give it a surface to sit on rather than floating in a void.
- The product itself: a torus knot with a MeshStandardMaterial using mid metalness (~0.55) and low roughness (~0.28), so it reads as a rich metallic finish.
- Color swatch buttons carrying their color in a data attribute; clicking one calls material.color.set() and nothing else, since the render loop is already running.
- A resize() function that reads canvas.clientWidth/clientHeight, calls renderer.setSize(w, h, false) — with a comment explaining that the false prevents Three.js writing inline styles that would override the CSS sizing — sets camera.aspect and calls camera.updateProjectionMatrix(). Call it once before the first frame and bind it to the window resize event.
- A requestAnimationFrame loop that calls controls.update() (required for damping and autoRotate to work at all) and then renderer.render(scene, camera).

Final thought

The thing worth taking from this snippet is the ratio. One line creates the object. Around two dozen arrange the light around it, give it a floor, and tell the camera how to look at it — and those two dozen are what decide whether the result looks like a product or like homework.

That ratio holds well beyond this file. When a 3D scene on the web looks cheap, the fix is almost never a better model; it's a key light with a real direction, a fill that keeps the shadows alive, a rim that cuts the silhouette out of the background, and something for the object to stand on. Learn those four moves once and every object you ever load will arrive looking photographed.

Grab the full HTML, CSS, and JS — or export it straight to React, Vue, Angular, or Tailwind — at Three.js Product Viewer on FWD Tools. It's free, runs entirely in your browser, and needs no sign-up. Browse more components like this one at FWD Tools UI Snippets.

About the author

Puneet Sharma
Puneet Sharma is a freelance web developer, tech writer, and blogger. He is the founder of FWD Tools and runs WebDevPuneet and The Tech Watcher.

Post a Comment