Three.js From Zero · Article s11-08

S11-08 The Production Configurator

Skip to configurator
Season 11 · Article 08 · Configurator Capstone

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.

S11 Configurator capstone · Part 1 of the season finale · The full stack in one HTML file

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.

init…
Shade — clearcoat metal
Diffuser — frosted glass, IOR 1.5
Arm — solid brass, 380g

Shade color

Shade finish

Arm material

Cord color

Bulb temperature

Total $420

Every interaction emits a synthetic 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 Chromeintent://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)

  1. Quarter 1 — ship 2–3 Shopify <model-viewer> integrations (S11-07 recipe). $5k–$15k each. Validates market, fills calendar, builds case studies.
  2. 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.
  3. 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.
  4. 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.
  5. 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

#ArticleWhat it gives you
S11-01Configurator ArchitectureState model, render loop, the canonical project layout
S11-02Material / Color PickerPer-part material binding, finish swap, swatch UI
S11-03AR Quick Look + Scene Viewer + WebXRThe three-platform AR dance
S11-04Watch Industry StackAnisotropic dial, clearcoat crystal, strap material picker
S11-05Eyewear / Sneaker Try-OnMediaPipe face/pose tracking + atlas binding
S11-06Modular FurnitureSnap-points, parametric assembly, Roomle-style rules
S11-07Shopify Integration RecipeThe freelance entry point — <model-viewer> in Liquid + Hydrogen
S11-08Production 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 configState object. 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