Three.js From Zero · Article s11-08
S11-08 The Production Configurator
The Production Configurator — stacking everything from S11-01 to S11-07
A polished single-file demo of the full configurator stack: architecture, picker UI, AR routing, share state, hotspot annotations, photo-mode, analytics, and a11y. Steal it. Ship it. Sell it.
1. What this article is
Six articles ago we drew a configurator architecture (S11-01). Five articles ago we built the material picker (S11-02). Then we wired AR (S11-03), studied the watch industry (S11-04), did face-tracked try-on for eyewear and sneakers (S11-05), assembled snap-point furniture (S11-06), and recipe-fied the Shopify deployment (S11-07).
This is the demo where it all comes together — not a tutorial-grade toy, but the closest a single-file HTML can get to a production configurator. A piece of merchandise (an industrial pendant lamp — clearcoated metal shade + frosted glass diffuser + brass arm + cloth cord), with the full picker UI, share-URL state, AR CTA, photo-mode export, hotspot annotations, GA4-shaped analytics, and keyboard navigation throughout.
Read the article. Then read the source. The whole thing is ~720 lines and every block maps to one of the season's earlier articles.
2. The stack — what's in here, where it came from
Architecture S11-01
One configState object as the source of truth. Pickers mutate it; render reads from it. URL-encodable.
Material / color picker S11-02
Per-part material slots. Color swatches re-bind baseColor; finish select swaps metalness/roughness.
AR routing S11-03
"View in your space" CTA. Generates iOS Quick Look (USDZ) link or Android Scene Viewer intent based on UA. WebXR fallback noted in source.
Hotspot annotations S11-04, S7-03
3D-anchored DOM overlays. Watch-industry pattern: "case material," "dial finish," "movement." Occlusion-aware fade.
Snap-aware variant rules S11-06
Mini constraint solver. Brass arm + matte black shade only — disables incompatible swatches with explanation.
Share state S11-01, S8-01
Encode configState as base64-URL. Paste back in browser → identical configuration.
Photo-mode S5-04, S7-10
Offscreen render at 4× pixel ratio. Exports a 2048-wide PNG to download. Replaces a $50k catalog photoshoot.
Analytics S8-09, S11-07
Event emitter mirroring GA4 / Shopify Web Pixels payload shape. 3d_* events flow to a console-visible log.
Accessibility S7-07
Keyboard nav for all pickers, skip-link, aria-live log, aria-pressed swatches, focus rings.
3. The configurator — full demo
The lamp is on a turntable. Pick parts on the right, press Tab to traverse pickers, Space on a swatch to apply, 1/2/3 to focus hotspots. Press the camera-icon button for a 4× photo render — saves a PNG. Press "View in your space" to fire the AR routing.
Shade color
Shade finish
Arm material
Cord color
Bulb temperature
Total $420
3d_* event in the log. In production, that's the payload shape for GA4 / Shopify Web Pixels / Klaviyo. The log is keyboard-readable via aria-live="polite".4. The state object — the heart of the architecture
Everything in the demo flows from one object:
const configState = {
shade: { color: '#1a1a1a', finish: 'clearcoat' },
arm: { color: '#b8954c' }, // brass
cord: { color: '#3a3a3a' }, // charcoal
bulb: { temperature: 3000 }, // Kelvin
rotate: true,
};
Pickers mutate it. The render loop reads from it on each tick. Share-state encodes it. Analytics events serialize it. One source of truth, no race conditions. See S8-01 for why this beats per-component state every time.
5. The variant rule engine (50 lines, beats spreadsheets)
Real configurators have constraints: "the brass arm only ships with the matte black shade because the lacquer color hasn't been tested with the brass finish." A toy way to express it:
const rules = [
// (state) => { conflict: 'message' } | null
(s) => (s.arm.color === '#704020' && s.shade.finish === 'clearcoat')
? { conflict: 'Aged copper arm cannot ship with clearcoat shade — matte only.' }
: null,
(s) => (s.shade.color === '#446b3a' && s.cord.color === '#8a2c2c')
? { conflict: 'Forest green shade with crimson cord is a custom order — adds 4 weeks.' }
: null,
];
function validate(state) {
const issues = rules.map(r => r(state)).filter(Boolean);
return { ok: issues.length === 0, issues };
}
On each picker change, run validate. If issues.length > 0, surface the message on the swatch tooltip. Lucid Air's designer + Specialized's bicycle configurator both ship rule engines that look more or less exactly like this.
6. The picker UI in plain HTML
No React. No Vue. Each picker is a button group with aria-pressed on the active option. Clicks dispatch configState[part].color = ... and re-render the relevant material. aria-pressed="true" doubles as the styling hook for the active state.
document.querySelectorAll('.swatch').forEach((btn) => {
btn.addEventListener('click', () => {
const part = btn.dataset.part;
const color = btn.dataset.color;
configState[part].color = color;
// Update aria-pressed within the radio group
btn.parentElement.querySelectorAll('.swatch').forEach((s) => {
s.setAttribute('aria-pressed', s === btn ? 'true' : 'false');
});
materials[part].color.set(color);
pushEvent('3d_picker_change', { part, color });
syncShareUrl();
});
});
7. Hotspot annotations the production way
DOM hotspots beat 3D billboards: native a11y, native typography, native styling. The trick is keeping their screen-space position synced to a 3D anchor each frame.
function projectAnchorToScreen(anchorWorldPos) {
const v = anchorWorldPos.clone().project(camera); // NDC
const x = (v.x * 0.5 + 0.5) * canvas.clientWidth;
const y = (-v.y * 0.5 + 0.5) * canvas.clientHeight;
const occluded = v.z > 1 || isBehindGeometry(anchorWorldPos);
return { x, y, occluded };
}
function tickHotspots() {
for (const [id, anchor] of hotspotAnchors) {
const { x, y, occluded } = projectAnchorToScreen(anchor);
const el = document.getElementById(id);
el.style.left = x + 'px';
el.style.top = y + 'px';
el.dataset.occluded = occluded ? 'true' : 'false';
}
}
The fade-when-occluded check is what separates "hotspot" from "floating button." S7-03 covers the variations.
8. Share state — base64 + Object.assign
Compress the state to a URL param, paste back, restore. The Polestar / Lucid configurators all do this — that's how a customer texts their configuration to a partner who opens it on their phone.
function encodeState(s) {
return btoa(JSON.stringify(s));
}
function decodeState(token) {
try { return JSON.parse(atob(token)); }
catch { return null; }
}
function syncShareUrl() {
const url = new URL(location.href);
url.searchParams.set('c', encodeState(configState));
history.replaceState(null, '', url);
}
// On load
const initial = new URLSearchParams(location.search).get('c');
if (initial) {
const decoded = decodeState(initial);
if (decoded) Object.assign(configState, decoded);
}
For configurators that need shorter URLs, hash configState server-side and store the mapping. configurate.com/c/abc7 is friendlier than a 200-char base64. Threekit and Roomle both ship server-side short codes.
9. Photo-mode — replacing the photoshoot
Catalog photography costs $30k–$80k for a typical product line. Photo-mode renders it on demand. The trick: use an offscreen WebGL renderer at higher pixel ratio, then download the framebuffer as PNG.
function capturePhoto({ width = 2048, height = 2048 } = {}) {
const offscreen = new THREE.WebGLRenderer({ antialias: true, alpha: true });
offscreen.setPixelRatio(1);
offscreen.setSize(width, height, false);
offscreen.toneMapping = THREE.ACESFilmicToneMapping;
offscreen.toneMappingExposure = renderer.toneMappingExposure;
const photoCam = camera.clone();
photoCam.aspect = width / height;
photoCam.updateProjectionMatrix();
offscreen.render(scene, photoCam);
const dataUrl = offscreen.domElement.toDataURL('image/png');
offscreen.dispose();
const a = document.createElement('a');
a.href = dataUrl;
a.download = 'lamp-' + Date.now() + '.png';
a.click();
}
Bumping the renderer pixel ratio works for screen-resolution photos but blurs at print resolution. The offscreen pattern above renders at the target dimensions directly — sharp at 2048, sharp at 8192.
Threekit's "Virtual Photographer" runs this server-side on AWS Batch with full PBR and ray-traced shadows. For a single-merchant freelance project, the offscreen client render is fine — and lets the customer download a proof of their configuration immediately.
10. AR routing — the three-platform dance
S11-03 covered this in depth. Recap:
- iOS Safari / Vision Pro →
<a rel="ar" href="lamp.usdz">opens Quick Look. Animated with Reality Composer, supports buy-now buttons inside AR. - Android Chrome →
intent://arvr.google.com/scene-viewer/...opens Scene Viewer with the glTF. - Headsets (Quest, Vision Pro browser) → WebXR session via
renderer.xr.enabled = true. - Desktop → no AR, but the orbit demo is the fallback.
document.getElementById('ar-btn').addEventListener('click', async () => {
const ua = navigator.userAgent;
const config = encodeState(configState);
if (/iPhone|iPad|iPod/.test(ua)) {
// iOS — Quick Look. The USDZ should be served pre-tinted with the chosen variant.
location.href = `/api/usdz?c=${config}`;
pushEvent('3d_ar_launch', { platform: 'ios' });
} else if (/Android/.test(ua)) {
const sceneViewerUrl =
`intent://arvr.google.com/scene-viewer/1.0?file=${encodeURIComponent('https://example.com/lamp.glb?c='+config)}` +
`&mode=ar_only#Intent;scheme=https;package=com.google.ar.core;end;`;
location.href = sceneViewerUrl;
pushEvent('3d_ar_launch', { platform: 'android' });
} else if (await navigator.xr?.isSessionSupported('immersive-ar')) {
renderer.xr.enabled = true;
const session = await navigator.xr.requestSession('immersive-ar');
renderer.xr.setSession(session);
pushEvent('3d_ar_launch', { platform: 'webxr' });
} else {
alert('AR is not supported on this device. Open this page on a phone to view in your space.');
pushEvent('3d_ar_unsupported', { ua: ua.substring(0, 50) });
}
});
11. Analytics — the events that pay for the next invoice
Every interaction emits an event with a flat payload. Mirrors GA4 / Shopify Web Pixels. The merchant gets numbers; you get next month's retainer.
function pushEvent(name, props = {}) {
const event = { name, ts: Date.now(), product: 'lamp-001', ...props };
// Production: window.gtag?.('event', name, props);
// window.shopify?.webPixels.publish(name, props);
appendToLog(event);
}
// Standard configurator events
pushEvent('3d_session_start', { source: location.pathname });
pushEvent('3d_picker_change', { part: 'shade', color: '#446b3a' });
pushEvent('3d_hotspot_click', { hotspot: 'shade' });
pushEvent('3d_share', { method: 'clipboard' });
pushEvent('3d_photo_capture', { width: 2048, height: 2048 });
pushEvent('3d_ar_launch', { platform: 'ios' });
pushEvent('3d_add_to_cart', { config: encodeState(configState), price: 420 });
12. Accessibility — the part nobody markets but everybody benefits from
- Skip-link — first focusable element jumps to the configurator demo. Bypasses the article.
- Keyboard-first pickers — every swatch / select is a real
<button>/<select>. Tab traversal is correct out of the box. - aria-pressed on swatches — screen readers announce "Forest green, pressed" instead of "button".
- aria-live="polite" on the event log — non-blocking announcement of state changes.
- Focus rings visible on all interactive elements (no
outline: none). - prefers-reduced-motion media query disables auto-rotate. (See lines ~640 of the source.)
- Hotspot buttons labeled with full text content, not just a number.
S7-07 expands this to full WCAG-AA. For most freelance Shopify rollouts, the above is what merchants and US Section 508 audits actually require.
13. The "if I had a million dollars" extensions
What this demo doesn't do, that a $200k flagship would:
- Server-side renders. Threekit-style headless-gl on AWS Batch. Email-ready hero stills, animated WebMs for marketing, "the customer's configuration" rendered into the order confirmation. Cost: ~$400/mo at modest scale.
- BIM integration. The next article (S11-09) does IFC.js for AEC. Furniture configurators that drop into Revit / SketchUp / Rhino are an underserved B2B niche worth $100k–$500k per project.
- 3D model authoring loop. Plug Generative-3D (Meshy / Rodin / Trellis from S10-08) into the configurator pipeline so the merchant uploads a hero image and gets a base mesh + variant slots auto-generated. The $500k/year SaaS opportunity.
- Splat-based real-room context. S11-10/11. User captures their living room as a Gaussian splat once; thereafter every furniture configurator they touch composites against it. The IKEA Kreativ pattern, generalized.
- Server-rendered USDZ with audio. Reality Composer Pro + iOS 26's Spatial Scenes. AR Quick Look becomes a mini storefront — buy button inside the AR view, narration playing on tap.
- Per-customer pricing engine. The configurator drives BOM → MRP → real-time delivery date. Specialized ships this for bicycles. It's the difference between a viewer and an order system.
Most of these are the next 3–5 invoices for a competent freelancer who used the S11 articles to land the first one. The dossier (§5) maps each to revenue paths.
14. The freelance / SaaS roadmap (from the dossier §5)
- Quarter 1 — ship 2–3 Shopify
<model-viewer>integrations (S11-07 recipe). $5k–$15k each. Validates market, fills calendar, builds case studies. - Quarter 2 — sell a custom Three.js configurator using the S11-08 stack to one of those merchants when they outgrow
<model-viewer>. $30k–$80k. Now you have a flagship. - Quarter 3 — bundle S11-01 through S11-08 as a $299–$599 course. Distribute via Gumroad / your newsletter / Egghead. Ten sales a week pays the rent on its own.
- Quarter 4 — productize the most-repeated parts (the picker UI, the analytics subclass, the photo-mode renderer) as a hosted SaaS. Stripe checkout. Shopify app store listing. $99–$499/mo per merchant.
- Year 2 — pick a vertical (watches, eyewear, furniture). Become the freelancer + SaaS that everyone in that vertical references. Threekit + Roomle have head starts but their pricing is opaque and their DX is enterprisey. Differentiate on price + DX + vertical depth.
15. Season 11 recap — the eight configurator articles, one slate
| # | Article | What it gives you |
|---|---|---|
| S11-01 | Configurator Architecture | State model, render loop, the canonical project layout |
| S11-02 | Material / Color Picker | Per-part material binding, finish swap, swatch UI |
| S11-03 | AR Quick Look + Scene Viewer + WebXR | The three-platform AR dance |
| S11-04 | Watch Industry Stack | Anisotropic dial, clearcoat crystal, strap material picker |
| S11-05 | Eyewear / Sneaker Try-On | MediaPipe face/pose tracking + atlas binding |
| S11-06 | Modular Furniture | Snap-points, parametric assembly, Roomle-style rules |
| S11-07 | Shopify Integration Recipe | The freelance entry point — <model-viewer> in Liquid + Hydrogen |
| S11-08 | Production Configurator (this article) | The capstone — full stack in one HTML |
16. Season 11 second half — the high-leverage 2026 stack
This article closes the configurator track. The remaining seven articles are the flagship Master Part F gaps — high-leverage 2026 updates that compound the configurator content.
- S11-09 IFC.js BIM Viewer in 200 Lines — opens the AEC / B2B side. Configurator UX, but for buildings.
- S11-10 Spark.js — the 2026 Splat Renderer — Gaussian splatting, the new capture format. Replaces NeRF for product / room context.
- S11-11 Nerfstudio → Three.js Bridge — the production pipeline for splat training → web deployment.
- S11-12 R3F v10 — WebGPU + TSL Hooks — the new stack inflection. R3F's WebGPU support is finally usable.
- S11-13 WebXR Layers + Depth Sensing — the XR refresh. AR glasses ship in 2027; this is the prep.
- S11-14 Hillaire 2020 Atmosphere — the missing Three.js library. Real atmospheric scattering for hero scenes.
- S11-15 8th Wall → Niantic Migration Playbook — time-sensitive. 8th Wall sunsets Feb 2027. This is the last-call article.
17. Season 12 preview
S12 will be "Multiplayer / Live Worlds" — taking the production-architecture muscles from S8 + S11 into shared 3D spaces. WebRTC voice/video over 3D scenes (S9 follow-up), authoritative servers with rapier-rs (S2 follow-up), a multiplayer configurator pattern, splat-based co-presence, MR / Vision Pro shared sessions. Less revenue-shaped than S11; more "what's the next interesting platform."
18. Takeaways
- One
configStateobject. Pickers mutate, render reads, share-state encodes. Don't fragment. - Variant rules as plain functions. 50 lines beats a spreadsheet plus three meetings.
- Hotspots = DOM, not 3D billboards. Project per-frame; fade when occluded.
- Photo-mode = offscreen renderer at 4× resolution. Replaces a $50k photoshoot.
- AR routing: iOS Quick Look, Android Scene Viewer, WebXR. Three branches.
<model-viewer>abstracts it; custom Three.js has to do it manually. - Analytics events are the data that funds the next invoice. Wire them on day one.
- Accessibility is cheap and required. Don't ship without it.
- Use S11-07 as the entry point. Use S11-08 as the upsell. Use the dossier §5 as the multi-quarter business plan.
How long does this demo source take to read?
~720 lines of JavaScript + ~200 lines of CSS. About 25 minutes end-to-end if you've read S11-01 through S11-07. Every block is annotated with the article it came from.
— S11 configurator capstone · all demos MIT-licensed · the configurator track ends here, the second half of S11 starts at S11-09