Shoppers want to see the exact colour, finish and options they are paying for, and flat photos of every combination quickly become impossible to produce. A 3D product configurator built with Three.js solves this by rendering one model that changes in real time as the customer picks options. This guide covers the full build: the asset pipeline, loading, variant switching, lighting, controls, screenshots, performance, SEO and connecting the result to a WooCommerce or Shopify cart.
Start with the glTF asset pipeline
Most configurator problems start with the model, not the code. glTF (usually the binary .glb form) is the standard format for the web, and Blender exports it directly. Before writing any JavaScript, agree on these rules with whoever makes the 3D assets:
- Name every configurable part. Meshes and materials such as
Seat,FrameandLeg_Materialbecome the keys your code uses to find and change them. - Separate configurable surfaces. If the seat fabric and the stitching need different colours, they need different materials.
- Apply transforms and set the origin so the product sits at the world origin at real-world scale (glTF uses metres).
- Keep the polygon count reasonable and bake fine detail into normal maps.
- Compress the output. Draco or Meshopt for geometry, and KTX2 or WebP for textures, using a tool such as gltf-transform.
Optional parts, like armrests or a headboard, can live in the same file as separate nodes that you show and hide. If the catalogue is large, load optional parts as separate files on demand instead.
Load the model with GLTFLoader and DRACOLoader
Draco-compressed files need a decoder. Copy the decoder files from three/examples/jsm/libs/draco/ into your public folder so you are not depending on a third-party CDN.
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
const draco = new DRACOLoader().setDecoderPath('/draco/');
const loader = new GLTFLoader().setDRACOLoader(draco);
const gltf = await loader.loadAsync('/models/chair.glb');
const product = gltf.scene;
scene.add(product);
// Index configurable parts by name for fast lookups later
const parts = {};
product.traverse((obj) => {
if (obj.isMesh) parts[obj.name] = obj;
});
Show a progress indicator while loading. The onProgress callback of loader.load or a THREE.LoadingManager gives you the data you need.
Swap colours, materials and textures per variant
Each option the customer picks maps to a change on one or more parts. Keep that mapping in data, not scattered through event handlers.
const options = {
fabric: {
oak: { color: '#b08a5a', roughness: 0.8 },
slate: { color: '#4a5560', roughness: 0.9 },
},
frame: {
black: { map: '/textures/metal-black.webp', metalness: 1 },
brass: { map: '/textures/metal-brass.webp', metalness: 1 },
},
};
const textureLoader = new THREE.TextureLoader();
function applyFabric(key) {
const opt = options.fabric[key];
const mat = parts.Seat.material;
mat.color.set(opt.color);
mat.roughness = opt.roughness;
requestRender();
}
function applyTexture(mesh, url) {
textureLoader.load(url, (tex) => {
tex.colorSpace = THREE.SRGBColorSpace;
tex.flipY = false; // glTF UV convention
mesh.material.map?.dispose();
mesh.material.map = tex;
mesh.material.needsUpdate = true;
requestRender();
});
}
A few details that catch people out:
- Shared materials. If two meshes share a material in the glTF, changing one changes both. Clone the material (
mesh.material = mesh.material.clone()) when parts must vary independently. - Colour space. Colour textures need
SRGBColorSpace; normal, roughness and metalness maps should stay linear. - Texture orientation. Textures used on glTF models need
flipY = false. - KTX2 textures, if you use them, must be loaded with
KTX2Loaderrather thanTextureLoader. - Preload the textures for the most popular options so switching feels instant.
glTF also has an official extension, KHR_materials_variants, which stores several material sets in one file. Three.js has an example showing how to switch between them, and it works well when the variants are fixed at modelling time.
Light the product with an environment map
Products sell on materials, and metals, glossy plastics and lacquer only look right when they have something to reflect. Image-based lighting from an HDRI environment does most of the work, with one directional light added for shadows if needed.
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
renderer.toneMapping = THREE.NeutralToneMapping;
renderer.outputColorSpace = THREE.SRGBColorSpace;
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
RoomEnvironment is a neutral studio setup generated in code, so there is nothing to download. For a specific look, load an .hdr file with Three.js’s HDR loader (RGBELoader, called HDRLoader in newer releases) and pre-filter it with PMREMGenerator. NeutralToneMapping is designed to keep base colours close to their real values, which matters when a customer is choosing between two shades of grey.
A soft contact shadow under the product grounds it in space. A baked shadow texture on a plane is far cheaper than a real-time shadow map and usually looks better.
Set sensible OrbitControls limits
Unrestricted controls let users zoom inside the product, flip under the floor or lose it off-screen. Constrain them to useful views:
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0.5, 0);
controls.enablePan = false;
controls.minDistance = 1.2;
controls.maxDistance = 4;
controls.minPolarAngle = Math.PI * 0.15;
controls.maxPolarAngle = Math.PI * 0.5; // never below the floor
controls.enableDamping = true;
controls.addEventListener('change', requestRender);
Add preset camera views (front, side, detail) as buttons, and animate the camera between them. Many shoppers never drag the model at all, so presets make sure they still see the important angles.
Keep UI state and 3D state in sync
The most maintainable configurators treat the 3D scene as a view of one configuration object. The UI changes the configuration; a single function applies it to the scene.
const state = { fabric: 'oak', frame: 'black', armrests: true };
function setOption(key, value) {
state[key] = value;
applyConfig(state);
history.replaceState(null, '', '?' + new URLSearchParams(state));
}
function applyConfig(cfg) {
applyFabric(cfg.fabric);
applyTexture(parts.Frame, options.frame[cfg.frame].map);
parts.Armrests.visible = cfg.armrests;
requestRender();
}
This gives you several things for free: shareable links (the configuration is in the URL), easy restoring of a saved design, and a single place to validate combinations that are not allowed. In React or react-three-fiber the same idea applies; keep the configuration in a store such as Zustand and let components read from it.
Screenshots and “save your design”
Customers like to save or share what they built, and sales teams want an image attached to the order. The canvas can be exported with toDataURL or toBlob, but WebGL clears the drawing buffer after each frame is shown, so an export at a random moment can come back blank.
One fix is preserveDrawingBuffer: true on the renderer, but that can reduce performance on some devices. A better approach is to render and capture in the same task:
function captureDesign() {
renderer.render(scene, camera);
return new Promise((resolve) =>
renderer.domElement.toBlob(resolve, 'image/png')
);
}
For higher-quality images, temporarily raise the pixel ratio or render to a fixed-size offscreen target, then restore the normal settings. Store the image alongside the configuration JSON, not instead of it, so the design can always be reloaded and edited.
Plan for mobile performance
A large share of shoppers will open the configurator on a phone. Cap the device pixel ratio at 2, render only when the configuration or camera changes instead of running a constant loop, compress geometry and textures, and avoid real-time shadows where a baked one will do. My Three.js performance optimization guide covers each of these in detail.
Accessibility and SEO fallbacks
A canvas is invisible to search engines and screen readers, so the configurator must sit on top of a normal, crawlable product page rather than replace it.
- Use real HTML controls. Options should be buttons, radio groups or selects with labels, not elements drawn inside the canvas. That makes them keyboard and screen-reader friendly.
- Announce changes. An
aria-liveregion that reads “Frame: brass” confirms each selection. - Describe the canvas. Give it
role="img"and anaria-labelthat reflects the current configuration. - Render static images in HTML. Include product photos or pre-rendered images in the page markup, so the page has meaningful images if WebGL fails and for image search.
- Add Product structured data with name, image, description and offers, generated server-side from the same product data.
- Lazy-load the 3D viewer after the main content so it does not slow down the initial page load.
Connect the configurator to the cart
The configurator is only useful if the chosen design reaches checkout correctly. The cleanest approach is to map each valid configuration to a real variant in your store.
WooCommerce
Create a variable product whose attributes match your options, then look up the variation ID for the chosen combination. You can add it with WooCommerce’s Store API (POST /wp-json/wc/store/v1/cart/add-item with id and quantity), which requires the nonce WooCommerce provides to the page. For options that are not variants, such as engraving text, store them as cart item data through a small plugin hook and show them on the order.
Shopify
Shopify’s Ajax Cart API accepts a variant ID plus optional line item properties:
await fetch('/cart/add.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
items: [{
id: variantId,
quantity: 1,
properties: { Fabric: state.fabric, Frame: state.frame },
}],
}),
});
Whatever the platform, the price must come from the store, never from the browser. If the configuration affects price, resolve it to a variant or calculate it on the server.
Key takeaways
- Agree a naming convention and material split with the 3D artist before coding.
- Ship compressed
.glbfiles and load them with GLTFLoader plus Draco or Meshopt. - Drive variants from a data map and clone shared materials.
- Use environment lighting with PMREM and a neutral tone mapper for accurate colour.
- Limit OrbitControls and offer preset views.
- Keep one configuration object as the source of truth, mirrored in the URL.
- Render and capture in the same task instead of enabling
preserveDrawingBufferby default. - Keep a crawlable HTML product page with static images and Product schema.
- Map configurations to real store variants and keep pricing on the server.
Frequently asked questions
Is Three.js good for building a product configurator?
Yes. Three.js gives you full control over loading, materials, lighting and UI integration, and it works with any e-commerce platform. Ready-made viewers are faster to start with but are harder to customise deeply.
What 3D file format should a web configurator use?
Use glTF, ideally as a single compressed .glb file. It is the standard format for real-time 3D on the web and is supported natively by Three.js’s GLTFLoader.
Does a 3D configurator hurt SEO?
Not if it enhances a normal product page instead of replacing it. Keep the product text, static images and structured data in the HTML, and lazy-load the 3D viewer so it does not slow the initial render.
How do I add a configured product to a Shopify or WooCommerce cart?
Map each configuration to a real product variant and add that variant ID to the cart through the platform’s cart API. Store extra choices as line item properties or cart item data, and keep pricing on the server.
I have built this kind of system before, including a 3D product customizer designed to work across different products. If you want a configurator for your store, contact me to discuss your products and platform.