Three.js From Zero · Article s11-01
S11-01 Three.js Product Configurator from Scratch
Three.js Product Configurator from Scratch
From an empty <div> to a working product configurator with material-slot binding, URL share state, IBL via PMREM, and a UI panel. The architecture every retailer's $30k-$200k 3D viewer is built on.
1. What we're building (and why this article exists)
Every retailer with more than a few million dollars in annual revenue is now either shipping a 3D configurator or budgeting for one. The market is roughly $1.5B today, projected ~$8B by 2030, dominated by Three.js. Threekit, Roomle, Cylindo, Hapticmedia at the platform tier; Lusion, Active Theory, Demodern, Bureaux at the agency tier. Every flagship build (Polestar, BMW, Cartier, Nike By You, IKEA Kreativ) is a Three.js scene wired to a UI layer.
What's strangely missing from the tutorial market is the full architecture. There are tutorials about loading a glTF, tutorials about swapping a color, tutorials about OrbitControls. There are no tutorials that say: here's mounting, scene setup, options state, material-slot binding, URL share, IBL, and a UI panel — together — in one file. This article is that tutorial.
By the end you'll have a working configurator: pick a color, pick a material finish, copy the URL, paste it in another tab, and the scene rebuilds with your selections. That's the spine of every production configurator.
2. The architecture in one picture
┌────────────────────────────────────────────────────┐
│ store (Zustand-flavored vanilla, see S8-01) │
│ { color, finish, ... } │
└────────┬───────────────────────────────┬────────────┘
│ subscribe │ setState
▼ ▲
┌────────────────┐ ┌──────┴──────┐
│ Three scene │ │ UI panel │
│ - meshes │ │ - swatches │
│ - materials │ │ - selects │
│ - cameras │ │ - share btn│
└────────┬────────┘ └─────────────┘
│ bound to material slots
▼
GPU draw at 60fps
This is the S8-01 split applied to a configurator. The store is the only source of truth. The scene reads from it. The UI writes to it. URL state is just a serialized snapshot. Once you internalize this split, every other configurator pattern (rule engines, AR, analytics, persistent carts) bolts on cleanly.
Why the split matters
- Testable. You can unit-test the store without a GPU.
- Shareable. Serialize the store, you have a share URL.
- Server-renderable. The same store hands off to a headless renderer for catalog stills (S6-08 territory; the Threekit pattern).
- Replayable. Analytics records a sequence of
setStatecalls; you can replay exactly what a user did.
3. Mounting and the render loop
Real configurators almost always mount into a host <div>, not full-screen. Use ResizeObserver — the host can resize from CSS, drawer-open animations, or device rotation, and you want a clean DPR-aware response.
const host = document.getElementById('viewer');
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(host.clientWidth, host.clientHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
host.appendChild(renderer.domElement);
new ResizeObserver(() => {
const w = host.clientWidth, h = host.clientHeight;
renderer.setSize(w, h);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}).observe(host);
ACES Filmic tone mapping is the industry default — every flagship configurator (Polestar, BMW, Cartier) ships with it on. You almost never want the linear default for a product render.
4. The store (Zustand-flavored vanilla)
You don't need React or a 200KB state library for this. A 30-line vanilla store with subscribe / getState / setState covers 95% of configurators. Inspired by Zustand, written with no dependencies.
function createStore(initial) {
let state = initial;
const listeners = new Set();
return {
getState: () => state,
setState: (patch) => {
state = { ...state, ...patch };
listeners.forEach(fn => fn(state));
},
subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn); },
};
}
const store = createStore({
color: '#10b981',
finish: 'matte',
cushion: '#f4d8b6',
});
Two virtues: every state change notifies subscribers, and the entire state is a plain JSON object — which means we can serialize it for URL share for free in §6.
5. Binding store state to material slots
The technical heart of every configurator is material-slot binding. The Polestar / BMW / Cartier pattern: one mesh, multiple named material slots, swap material properties when state changes. You don't reload the model. You don't rebuild the scene graph. You poke a property on a MeshPhysicalMaterial and Three.js does the rest.
// In real builds, slots come from glTF userData or named primitives.
// Here we build them by hand for clarity.
const slots = {
body: new THREE.MeshPhysicalMaterial({ color: 0x10b981, roughness: 0.5, metalness: 0.0 }),
cushion: new THREE.MeshPhysicalMaterial({ color: 0xf4d8b6, roughness: 0.85 }),
};
// Bind: store change → material update
store.subscribe((s) => {
slots.body.color.set(s.color);
slots.cushion.color.set(s.cushion);
if (s.finish === 'matte') { slots.body.roughness = 0.85; slots.body.metalness = 0.0; slots.body.clearcoat = 0.0; }
if (s.finish === 'glossy') { slots.body.roughness = 0.18; slots.body.metalness = 0.05; slots.body.clearcoat = 1.0; }
slots.body.needsUpdate = true;
});
Notice needsUpdate = true. Three.js caches shader programs; for properties that change a uniform you don't need it, but if you toggle features that affect defines (like flipping clearcoat on for the first time), you do. Setting it always is a safe default.
scene.traverse to collect named meshes. The named slots become a map. The same binding pattern applies — only the meshes are loaded instead of authored.6. URL state encode and decode
The "share my chair" button is what sells. It also auto-bookmarks the user's progress, which lifts conversion. Two encodings are common:
- Query params per option:
?color=10b981&finish=matte. Human-readable. Best for SEO and analytics. - Base64 JSON blob:
?s=eyJjb2xvciI6Ii4uLn0. Compact, opaque, evolves as the schema grows.
Use option 1 until the URL gets ugly (~6+ options), then switch. Both are 10-line implementations:
function encodeURL(state) {
const url = new URL(location.href);
Object.entries(state).forEach(([k, v]) => url.searchParams.set(k, v));
return url.toString();
}
function decodeURL() {
const sp = new URLSearchParams(location.search);
const out = {};
for (const [k, v] of sp) out[k] = v;
return out;
}
// On boot:
store.setState(decodeURL());
// On share:
navigator.clipboard.writeText(encodeURL(store.getState()));
7. IBL via PMREM (the production lighting setup)
A "Studio" HDRI piped through PMREMGenerator is the universal product-render lighting. Polyhaven's "studio_small" series, "photo_studio_loft", or any RoomEnvironment built-in are the typical defaults. We'll use RoomEnvironment here so the demo runs offline; in production you'd load an HDR with RGBELoader or EXRLoader and feed it the same way.
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment(renderer), 0.04).texture;
That single texture lights every PBR material in the scene with a real-feeling bounce. Set scene.background = scene.environment if you want the room visible; leave it null for a neutral product shot.
8. Live demo — a configurable chair
A primitive "chair" composed from boxes and a cylinder. Three swatches drive body color, two finishes drive material, two cushion swatches drive the seat. Share copies the URL. Reset orbits back to the hero camera.
9. Camera reset (the hero camera)
Production configurators always have a "hero camera" — the angle the marketing team approves. OrbitControls lets the user wander, but they should be able to snap back. We tween camera.position and controls.target over ~600ms with an easing curve.
function resetCamera(targetPos, lookAt, ms = 600) {
const start = camera.position.clone();
const startTarget = controls.target.clone();
const t0 = performance.now();
function tween(now) {
const t = Math.min(1, (now - t0) / ms);
const e = 1 - Math.pow(1 - t, 3); // ease-out cubic
camera.position.lerpVectors(start, targetPos, e);
controls.target.lerpVectors(startTarget, lookAt, e);
controls.update();
if (t < 1) requestAnimationFrame(tween);
}
requestAnimationFrame(tween);
}
10. What's in the box vs. what's still ahead
| Concern | This article | Future S11 articles |
|---|---|---|
| Mounting + render loop | ✓ | — |
| Store split (S8-01) | ✓ | — |
| Material slot binding | ✓ | S11-04 watch industry deep dive |
| URL share state | ✓ | — |
| IBL / PMREM | ✓ | S6-02 (assets), S11-04 (luxury) |
| Polished UI / a11y | partial | S11-02 picker, S11-08 finale |
| AR pipeline | — | S11-03 |
| glTF asset pipeline | — | S6 series |
| Variant rule engine | — | S11-06 modular furniture |
| Shopify integration | — | S11-07 |
| Snap-points / parametric | — | S11-06 |
11. Production references
If you're building a configurator for a real client, study these in this order:
- Threekit — enterprise SaaS playbook. Their headless render + variant rule engine are the gold standard.
- Roomle — modular furniture / kitchens; best parametric snap-volume implementation in the wild.
- Polestar 4 / Polestar Precept (Lusion) — flagship art direction. HDRI-baked stage geometry is the trick.
- BMW Build Your Own (Demodern) — single mesh, swap material slots only. Same pattern as §5 above, scaled.
- Google's
<model-viewer>— long-tail "drop-in" deployment. Worth knowing where its ceiling is so you know when to graduate to custom Three.js.
12. Takeaways
- Store / scene split is the spine. Every other configurator concern hangs off it.
- Material-slot binding is the technical heart. One mesh, swap properties on shared materials.
- URL state is the conversion lever. Share buttons and bookmarkable URLs.
- PMREM IBL lights every PBR material for free with one line.
- ResizeObserver + DPR cap is the only mounting pattern that survives drawers, phones, and rotations.
- Architect for slots first, ship more slots later. The hard work is the wiring, not the meshes.