Design a 3D product configurator
The 3D viewer on a product page where shoppers spin a sneaker, sofa or car and change its parts and materials, like Nike By You, Tesla's configurator or Google's model-viewer. The hard parts are an asset pipeline that turns a 50 MB CAD export into a few megabytes of glTF, loading 3D without hurting the page, rendering only when something changes, a product schema with rules and prices, and AR and accessibility for a canvas that screen readers cannot see.
The question
Design the 3D configurator on a product page. A shopper sees a sofa they can rotate and zoom, picks the frame, the fabric, the leg style and the cushion count, sees the price change, shares the exact configuration with a link, adds it to the cart, and on a phone can place it in their living room with AR.
Walk me through how the 3D assets get to the browser, how you load them without hurting the product page, how you model the options and their rules, and what happens on a cheap Android phone and for a shopper using a screen reader.
Real-life examples: Nike By You, Tesla's car configurator, IKEA and furniture sofa configurators, Apple AR Quick Look on product pages, Shopify 3D models, Google's model-viewer web component
Requirements: a showroom inside a product page
A 3D configurator replaces the showroom. In a store you walk around a sofa, touch the fabric, and ask for it in green. On the web the shopper drags to rotate the sofa, taps a fabric swatch, and watches the sofa change. Nike By You lets shoppers design sneakers, Tesla lets them build a car, furniture brands do the same for sofas, and Shopify lets any merchant add a 3D model to a product page.
It is a component, not a whole app: it lives inside a product page that already exists. That page has its own job (sell the product, rank in search, load fast), so most of our decisions are about not getting in its way. The page itself, variants, reviews and the cart are covered in the amazon and shopping-cart guides, and 2D photos, swiping and zoom in image-carousel and image-lightbox. We link them and focus on what only 3D adds.
We also stay away from ground other guides own: drawing and hit testing on a 2D canvas is in figma and excalidraw, WebGL image processing is in photo-editor, and WebGL map tiles are in google-maps. Here the GPU draws a lit, textured 3D object, and the questions are about bytes, memory, battery and rules.
Questions to ask
| Question | Answer we assume | What it changes |
|---|---|---|
| How many products have 3D? | About 2,000 sofas and chairs, each with 5 to 30 options. New ones weekly. | Assets must come from a pipeline, never hand tuned per product. |
| What can change? | Fabric and color (materials), leg style and arm style (different shapes), cushion count (parts added or removed). | Some changes swap a material, some swap a mesh. They cost very different amounts. |
| Are there rules? | Yes. Velvet only comes in 6 colors, the chaise only fits the 3-seat frame, metal legs cost extra. | We need a schema with constraints, not a flat list of swatches. |
| Which devices? | About 70% of traffic is mobile; many are mid-range Android phones on 4G. | Download and GPU memory budgets are set by a mid-range phone, not a MacBook. |
| Is the product page SEO critical? | Yes. It ranks for the product name and must keep good Core Web Vitals. | The LCP element must be an image or text, and 3D must never block the first render. |
| AR? | Yes, on phones that support it: place the configured sofa in the room. | We need GLB for Android and USDZ for iOS, for every configuration. |
| What does the cart and email show? | An image of the exact configuration the shopper built. | We need a snapshot renderer, and a configuration that can be stored and replayed. |
| Who makes the 3D models? | A 3D team exports from CAD and Blender. Files arrive at 20 to 200 MB. | Compression, texture resizing and LOD are a build step, not a runtime step. |
Functional requirements
- Show a product image right away, then a 3D view the shopper can rotate, zoom and (sometimes) pan with mouse, touch and keyboard.
- Show option groups (frame, fabric, color, legs, cushions) as swatches and choices. Picking one updates the 3D view, the price and the summary text.
- Enforce rules: options that do not fit the current choices are disabled with a reason, never silently allowed.
- Hotspots: small labelled dots on the model ("Removable covers", "Solid oak legs") that stay attached to the right spot as it rotates.
- Share: the URL always holds the configuration, so a copied link opens the same sofa.
- Add to cart with the exact configuration, a SKU or build code, the price, and a picture of it.
- View in AR on supported phones, showing the configured sofa at real size.
- Work without 3D: on old devices, without WebGL2, or when 3D fails, the shopper can still configure and buy with images.
Non-functional requirements
- Page speed first: the product page keeps LCP under 2.5 seconds on a mid-range phone on 4G. The poster image is the LCP element, not the canvas.
- 3D within a few seconds: an interactive model within about 3 seconds of the shopper asking for it on 4G, with a low-detail model sooner.
- Download budget: under about 5 MB for the first 3D view (runtime plus model plus textures), an estimate we justify in the asset chapter.
- GPU memory budget: under about 150 MB of textures and buffers on phones (an estimate, chosen to leave room on a 4 GB device).
- Option change feels instant: a material change shows within 100 ms; a mesh change within about 500 ms, with a visible loading hint if slower.
- Battery: no rendering at 60 fps while nothing moves. A shopper reading the reviews should not drain their phone.
- Accessible: every option is a real form control, the current configuration is described in text, and nothing is shown only in 3D.
Out of scope
- Room planners where the shopper lays out a whole kitchen. That is an editor with snapping and collision, closer to figma in 3D.
- Authoring tools for the 3D team. We consume their exports through a pipeline.
- Photoreal ray tracing in the browser. We aim for good real-time rendering, and use offline renders where photo quality matters.
- Inventory, pricing engines and checkout. We call them; the shopping-cart and checkout-flow guides cover them.
What makes 3D on a product page hard
A photo carousel downloads a few images and lets the browser draw them. A 3D viewer downloads a program (the 3D engine) and a model (geometry and textures), uploads them to the GPU, then draws the whole scene again every time the camera moves. Six properties of the problem drive every later decision.
- The weight is hidden twice. A model has a download size (bytes on the network) and a GPU size (bytes in video memory), and they are different numbers. A 2 MB JPEG texture of 4096 by 4096 pixels becomes about 85 MB of GPU memory once decoded with mipmaps. Phones run out of the second long before the first.
- Optional for the sale, but on the most important page. Most shoppers buy from photos. The viewer must never slow the page, hide the price, or break the add to cart button, and if it fails the page must still work.
- Combinatorial. 4 frames, 40 fabrics, 3 leg styles and 2 cushion counts make 960 combinations. We cannot pre-build 960 models per product, so the browser assembles each build from parts, and the rules about which parts fit together live in data.
- Continuous drawing on a battery. Games draw 60 frames per second forever. A product page that does this heats the phone while the shopper reads reviews, so the viewer must draw only when something changes.
- Invisible to assistive technology and search. A canvas is a grid of pixels: a screen reader hears nothing and a search engine sees nothing. Every fact shown in 3D must also exist as text and real controls.
- Devices vary enormously. The same page runs on a desktop with a dedicated GPU and on a four year old Android phone with 3 GB of RAM, and a few browsers still lack WebGL2. The design needs tiers, not one experience.
The next chapters build the solution one decision at a time. A build pipeline turns raw exports into compressed glTF files at a few detail levels. The page shows a poster image first, loads the 3D engine only when it cannot hurt the page, and renders on demand. A product schema describes parts, options, rules and prices, and the AR files, the cart snapshot and the accessible text all read the same configuration, which lives in the URL.
Think first
Why not skip 3D and render a photo of every configuration on a server instead?
For a small catalog this is a real option, and some brands do it: pre-rendered or on-demand rendered images per combination, shown in a carousel. It is light on the phone and looks photoreal.
It breaks down with many combinations and with interaction. 960 combinations times 24 angles is 23,040 images per sofa. On-demand rendering adds a server round trip of hundreds of milliseconds per change and a render farm to pay for, and the shopper cannot rotate freely, zoom into a seam, or use AR. Our design keeps server rendering for the one job where it is best: the snapshot image for the cart and emails.
Check your understanding · Exercise 1
Which answers from the product team would change this design the most? Select all that apply.
The asset pipeline: from a 120 MB export to a 3 MB file
The 3D team exports a sofa from CAD or Blender, and a raw export is huge: 500,000 triangles because CAD keeps every screw, and 4096 by 4096 PNG textures because artists work at full detail. Say 120 MB on disk. Served as is, it takes about 96 seconds on 4G at a realistic 10 Mbps, and then the phone tries to decode the textures into GPU memory and the tab crashes.
So the first design decision is not in the browser at all. It is a build pipeline that runs every time the 3D team publishes a product, and turns the export into small, GPU-friendly files with a fixed budget. Nobody tunes files by hand; with 2,000 products, only automation keeps every file under budget.
The format: glTF 2.0 and GLB
glTF 2.0 is the Khronos standard for sending 3D scenes to a renderer, often called the JPEG of 3D. A glTF file is JSON that describes nodes, meshes, materials and cameras, and points at binary buffers (vertex data) and images (textures). GLB is the same thing packed into one binary file, which is what we serve: one request, no base64. Every web engine (three.js, Babylon.js, model-viewer), Android Scene Viewer and most 3D tools read it.
glTF materials are PBR (physically based rendering): each material has a base color, a metallic value, a roughness value and a normal map, and the renderer works out how light bounces from those. That matters for us because the same fabric looks right under any lighting, so we can mix fabrics and frames freely. glTF also has extensions, and four of them matter here: KHR_draco_mesh_compression and EXT_meshopt_compression for geometry, KHR_texture_basisu for KTX2 textures (explained below), and KHR_materials_variants, which stores several named material sets ("Navy velvet", "Grey linen") in one file.
Cache-Control: immutable.The hidden cost: textures in GPU memory
A JPEG is small on the network because it is compressed for storage, but the GPU cannot sample a JPEG. The browser decodes it to raw pixels, 4 bytes per pixel in RGBA, and uploads those. A 4096 by 4096 texture is 16,777,216 pixels, so 64 MB (MiB, to be exact).
Mipmaps grow it by a third, to about 85 MB. They are smaller copies at half, quarter and so on that the GPU samples when the object is far away, to avoid shimmering. A PBR material usually has three textures: base color, normal, and a packed occlusion, roughness and metallic map. That is about 256 MB for one material at 4K, more than many phones will give a tab.
GPU compressed formats fix this: the GPU reads them directly, still compressed, in blocks of 4 by 4 pixels. ASTC 4x4 and BC7 use 16 bytes per block (1 byte per pixel); ETC1 and BC1 use 8 bytes per block (half a byte per pixel). GPU families support different formats, though: phones mostly ASTC and ETC2, desktops mostly BC. KTX2 (a Khronos texture container) with Basis Universal solves that: we ship one file, and a small decoder in the browser transcodes it to a format the GPU supports, never going through raw RGBA.
Decision Which texture format should the pipeline produce?
What we pick: KTX2 for everything, mixed by map type: UASTC for normal maps and the hero fabric close-up, ETC1S for base color and the packed occlusion, roughness and metallic map. GPU memory, not download size, is what crashes phones, and only GPU formats cut it. We keep a WebP set only for the snapshot renderer and as a fallback for the rare browser where the transcoder fails.
| One texture | RGBA8 (JPEG or PNG decoded) | 1 byte per pixel (ASTC 4x4, BC7) | Half a byte per pixel (ETC1, BC1) |
|---|---|---|---|
| 4096 by 4096, no mipmaps | 64 MB | 16 MB | 8 MB |
| 4096 by 4096, with mipmaps | about 85 MB | about 21 MB | about 11 MB |
| 2048 by 2048, with mipmaps | about 21 MB | about 5.3 MB | about 2.7 MB |
| 1024 by 1024, with mipmaps | about 5.3 MB | about 1.3 MB | about 0.7 MB |
Geometry: simplify first, then compress
Geometry has the same two costs. Each vertex holds a position, a normal, texture coordinates and often a tangent: about 48 bytes as plain floats. A 150,000 triangle sofa has roughly 80,000 vertices, so about 4 MB of vertices plus about 1.8 MB of indices (three 4 byte indices per triangle).
The pipeline does three things. It simplifies, removing triangles nobody can see at product-page distance, like screw threads. It quantizes, storing positions and normals as 16 bit or 8 bit integers instead of 32 bit floats, which glTF allows through KHR_mesh_quantization. Then it compresses with one of two extensions.
| Geometry | How it works | Download | Decode in the browser |
|---|---|---|---|
| None (quantized only) | Smaller integer types, no codec. | About half of raw floats. | Free: the data goes to the GPU as is. |
Draco (KHR_draco_mesh_compression) | Encodes the mesh connectivity and predicts vertex values. Very high ratios. | Often the smallest file. | Needs a WebAssembly decoder: about 250 KB, 75 KB gzipped, in three.js r186. Decoding a big mesh can take tens to hundreds of ms on a phone (estimate). |
Meshopt (EXT_meshopt_compression) | Reorders and filters vertex and index buffers so they compress well, then a light codec. Pairs with gzip or Brotli from the CDN. | Close to Draco once the CDN compresses it. | Tiny decoder (about 30 KB, 8 KB gzipped) and very fast, typically well under Draco's cost. Also works for animation and morph data. |
We pick Meshopt: the download is close to Draco, the decoder is a fraction of the size, and decoding is fast enough to do on the main thread for small meshes or in a worker for large ones. Draco stays a valid choice when every byte matters and the device is fast. A file that uses either extension lists it as required, so the loader must have the matching decoder configured (setDRACOLoader or setMeshoptDecoder on three.js GLTFLoader), or loading fails.
Levels of detail and a budget per product
The pipeline writes each product at several levels of detail (LOD): LOD2 at about 10,000 triangles with 512 textures for the first view, LOD1 at about 40,000 triangles with 1024 textures, and LOD0 at about 150,000 triangles with 2048 textures for desktop and close zoom. Fabrics are separate, tiling material textures (a 1024 weave repeated across the cushion) shared by every product that offers that fabric, so 40 fabrics do not mean 40 copies per sofa.
The pipeline fails the publish if a product goes over budget. The first-view budget is an estimate built from parts, all compressed: about 200 KB of 3D runtime and viewer code, about 270 KB of decoders (mostly the Basis transcoder), about 1 MB for LOD2 with its textures, and about 0.5 MB for the default fabric. That is around 2 MB, leaving room under the 5 MB ceiling for LOD1, while GPU memory for the full LOD0 view must stay under about 150 MB. The lab shows how each choice moves the numbers.
Download 15 MB, GPU memory 133 MB, first view on 4G 12 seconds. Over the download budget for a first view.
# Per product, in CI, with the glTF Transform CLI (one step per line for clarity)
gltf-transform simplify raw.glb lod0.glb --ratio 0.3 --error 0.001
gltf-transform resize lod0.glb lod0.glb --width 2048 --height 2048
gltf-transform uastc lod0.glb lod0.glb --slots "normalTexture"
gltf-transform etc1s lod0.glb lod0.glb --slots "{baseColorTexture,occlusionTexture,metallicRoughnessTexture}"
gltf-transform meshopt lod0.glb lod0.glb
# Repeat with smaller ratios and sizes for lod1 and lod2, then:
node check-budget.mjs lod2.glb --max-download 1.2MB --max-gpu 40MB
Check your understanding · Exercise 2
A product's base color texture is a 4096 by 4096 JPEG of 2.5 MB. Which statements are true? Select all that apply.
Loading: 3D must never cost the page
The naive version puts a canvas at the top of the product page and loads three.js and the model with the page. The whole three.js library is about 185 KB minified and gzipped (Bundlephobia, r186), and a tree-shaken build is smaller. On a mid-range phone, parsing and running that much JavaScript can still take a few hundred ms of main thread (estimate), which delays the page's response to the first taps, measured by INP (Interaction to Next Paint). The model then downloads alongside the hero image and takes its bandwidth.
A canvas is also not an LCP candidate: Largest Contentful Paint only counts images, video, background images and blocks of text. So the product area is a blank box until the first frame, while LCP lands on some other element, slowed by the GLB competing for bandwidth. And most shoppers never touch the 3D at all.
The fix starts from one rule: the first thing on screen is a poster image, a normal img rendered from the same model in the default configuration. It is the LCP element, it is optimized like any product image (see the image-optimization and core-web-vitals lessons), and it looks exactly like the first frame of the 3D view, so the swap is invisible. The question is when to start loading the 3D behind it.
Decision When should the page start loading the 3D runtime and the model?
What we pick: Visible and idle, by tier. The page's job is to sell; it loads first and alone. Then capable devices quietly fetch the runtime and LOD2 so a drag starts rotating at once, while low tier devices and anyone with Save-Data keep the poster and a "View in 3D" button. The model-viewer web component does a version of this out of the box: its default loading value waits until the element is near the viewport.
Device tiers and the image fallback
Before loading anything heavy, a tiny script (under 2 KB, in the page bundle) decides the tier. It checks for WebGL2 by creating a context on a throwaway canvas, reads navigator.connection.saveData and effectiveType where available (Chromium only), navigator.deviceMemory (also Chromium only, rounded on purpose). Unknown values count as "medium". The result is one of three tiers: full 3D with LOD1 and LOD0 streaming, light 3D that stays on LOD2 with a lower pixel ratio, and no 3D.
No 3D is not "no product view". The fallback is a 360 degree spin: 24 to 36 frames rendered offline by the same pipeline, shown in an image strip that follows the pointer, which covers rotation (most of what shoppers do) for under about 1 MB of images. Configuration still works, because options are form controls and the poster for each material set can be rendered offline. The same fallback catches runtime failures: a decoder that does not load, a lost GPU context that will not come back, or a timeout.
type Tier = "full" | "light" | "none";
export function detectTier(): Tier {
const gl = document.createElement("canvas").getContext("webgl2");
if (!gl) return "none";
gl.getExtension("WEBGL_lose_context")?.loseContext();
const nav = navigator as Navigator & {
connection?: { saveData?: boolean; effectiveType?: string };
deviceMemory?: number;
};
if (nav.connection?.saveData) return "none";
const slowNet = ["slow-2g", "2g", "3g"].includes(nav.connection?.effectiveType ?? "4g");
const lowMemory = (nav.deviceMemory ?? 4) <= 2;
return slowNet || lowMemory ? "light" : "full";
}
export function whenVisibleAndIdle(el: Element, run: () => void) {
const io = new IntersectionObserver(([entry]) => {
if (!entry.isIntersecting) return;
io.disconnect();
(window.requestIdleCallback ?? ((cb) => setTimeout(cb, 200)))(run);
}, { rootMargin: "200px" });
io.observe(el);
}
Loading on idle still runs a lot of JavaScript once it starts. To protect INP, the viewer imports its chunk with a dynamic import() and splits setup (parsing the GLB, creating materials, the first GPU upload) into short tasks, yielding to the browser between them with scheduler.yield() where supported and setTimeout elsewhere. A tap on a swatch during setup then waits for one short task, not the whole setup.
Progressive loading: low detail first
When 3D starts, the viewer fetches LOD2 (about 1 MB), renders it as soon as it is ready, then fades the poster out: the shopper can rotate about 1.5 to 2 seconds after the trigger on 4G (estimate). In the background it fetches LOD1, and LOD0 only on full tier devices, then swaps the meshes in place without moving the camera. Textures follow the same pattern: 512 first, 1024 or 2048 later. Each step is a separate fetch with an AbortController, so leaving the page or switching product cancels the rest.
The viewer is a small state machine (see the state-machines lesson): poster, loading runtime, loading model, interactive, upgrading detail, fallback, and context lost. Writing it as states with allowed transitions keeps the edge cases honest: a tap during loading does not start a second load, an error in any loading state goes to the fallback, and a lost context goes to its own state instead of a blank canvas.
Check your understanding · Exercise 3
Which choices keep the product page's Core Web Vitals healthy while still offering 3D? Select all that apply.
Rendering: good pixels, drawn only when needed
A product is judged by how real it looks, so the scene setup matters more than in most WebGL work. Four settings do most of the work in three.js, and they map one to one to concepts in other engines.
- Image-based lighting. Instead of placing lamps, we light the product with an environment map: a 360 degree HDR photo of a softly lit studio.
HDRLoader(calledRGBELoaderbefore r180) loads it andPMREMGeneratorpre-filters it so rough fabric gets blurry reflections and chrome legs get sharp ones. One 1K HDR, about 1 to 2 MB, is shared by every product (estimate). - Color management and tone mapping. Textures are authored in sRGB, lighting is computed in linear space, and the result is mapped back to the screen. The tone mapper squeezes bright HDR values into the screen's range. For products,
NeutralToneMapping(Khronos PBR Neutral) is a good default because it keeps base colors close to the swatch, which matters when a shopper picks "Sage green". Filmic mappers like ACES shift hues, which looks good in games and can turn into returns in commerce. - Shadows: baked, not real time. The product sits on an invisible floor with a soft contact shadow. Real-time shadow maps cost an extra render pass and look jagged on phones. We bake ambient occlusion into the mesh's texture in the pipeline and draw the floor shadow from a pre-blurred texture, re-rendered only when the shape changes (adding the chaise), not when the fabric does.
- Antialiasing and pixel ratio.
antialias: trueasks for MSAA, which is cheap on mobile GPUs for simple scenes. A phone withdevicePixelRatioof 3 would render 9 times the pixels of a ratio of 1, so we cap it:Math.min(devicePixelRatio, 2)on full tier, 1.5 on light tier. The difference is hard to see on a moving 3D object; the GPU time is not.
Render on demand, not 60 times a second
Most three.js tutorials start a requestAnimationFrame loop that renders forever. On a product page, the sofa is still 95% of the time: the shopper is reading, scrolling, or comparing fabrics. A continuous loop renders 60 identical frames per second, keeps the GPU awake, and on a phone costs real battery and heat, up to twice as much on 120 Hz screens.
Decision How often should the viewer render?
What we pick: On demand. Each change source calls requestRender(), and anything that animates (damping, a material crossfade, an auto-rotate) keeps requesting frames only until it settles. An IntersectionObserver and visibilitychange stop requests when the viewer is off screen. Battery is a requirement here, not a nicety.
let pending = false;
let animating = 0; // number of active animations (damping, fades)
export function requestRender() {
if (pending || !visible) return;
pending = true;
requestAnimationFrame(() => {
pending = false;
const moving = controls.update(); // true while damping is settling
renderer.render(scene, camera);
hotspots.project(camera); // move DOM hotspots to their new 2D spots
if (moving || animating > 0) requestRender();
});
}
controls.addEventListener("change", requestRender);
configurator.on("applied", requestRender);
WebGL2 or WebGPU, main thread or worker
WebGPU now ships in Chrome and Edge (Windows, macOS and ChromeOS since 113, Android 12+ since 121), Safari 26, and Firefox on Windows (141) and Apple silicon Macs (145), with Linux and more platforms in progress (web.dev, November 2025). three.js ships a WebGPURenderer that falls back to WebGL2. WebGPU mainly lowers the CPU cost per draw call, which matters little for one sofa with 20 draw calls. So we render with WebGL2, which reaches more devices, and treat WebGPU as an upgrade path; our glTF and KTX2 assets work with both.
OffscreenCanvas lets a worker own the canvas (canvas.transferControlToOffscreen()), so rendering never blocks the main thread, at the cost of forwarding pointer events to the worker and posting hotspot positions back. One product takes a few ms per frame and renders only on demand, so we keep rendering on the main thread and move the heavy, bursty work into workers: KTX2Loader transcodes in a worker pool by default, and the Meshopt decoder does after MeshoptDecoder.useWorkers(). A worker renderer is the next step if field data shows long tasks.
Check your understanding · Exercise 4
A shopper has the viewer open on a phone and is reading reviews below it. What should the viewer be doing? Select all that apply.
Configuration as data: parts, options, rules and prices
The naive configurator hard-codes the sofa: an if for "velvet only comes in 6 colors", a switch for leg meshes, prices in the component. It works for one product. With 2,000 products, merchandisers changing prices weekly, and fabrics retiring every season, every change is a deploy. The fix is the idea in the config-driven-ui lesson: a product schema, served as JSON, that describes everything, and a generic configurator that reads it.
type ProductSchema = {
productId: string;
version: number; // bumps when options or rules change
basePrice: number; // minor units, e.g. cents
groups: OptionGroup[]; // shown in this order
rules: Rule[];
sku: { pattern: string }; // "SOF-{frame}-{fabric}-{color}-{legs}"
};
type OptionGroup = { id: string; label: string; options: Option[]; defaultId: string };
type Option = {
id: string; // short and stable: used in URLs and SKUs
label: string;
priceDelta: number;
swatch?: string; // image URL for the button
apply: // what the viewer does
| { kind: "material"; slot: string; materialUrl: string }
| { kind: "mesh"; slot: string; meshUrl: string; lods: string[] }
| { kind: "variant"; name: string }; // KHR_materials_variants
};
type Rule =
| { kind: "only-with"; when: Pick; allow: Pick[]; reason: string }
| { kind: "excludes"; when: Pick; deny: Pick[]; reason: string };
type Pick = { group: string; option: string };
Swapping materials versus swapping meshes
Every option does one of two very different things in the scene. A material swap changes how a part looks: a new color only updates a value passed to the shader, while a new fabric is a texture set of 0.3 to 1 MB to download and upload. A mesh swap changes a part's shape, like metal legs or the chaise, which means downloading and decoding new geometry and redrawing the floor shadow. The schema says which is which, so the UI can predict the cost and show a loading hint only where needed.
Decision How should the changeable parts be packaged as files?
What we pick: A base file, part files and a shared material library. It matches how shoppers behave: they look at the default, try a few fabrics, and change shape once or twice. It also matches the CDN: fabric files are immutable, shared, and cached for a year. Variants inside a file are still used where a set is small and always wanted together, like the four finishes of one leg.
Rules, conflicts and prices
The rules engine is a pure function: given the schema and the current picks, return each option's state (allowed, or disabled with a reason) and the price. It runs on every change in well under a millisecond for a few hundred options, and the same code runs on the server to validate the cart, so the client and the server can never disagree about what is buildable.
The tricky case is a conflict: the shopper has "Mustard" color, then switches to velvet, which does not come in mustard. Silently keeping an invalid build is a bug; blocking the fabric change is frustrating. We switch the color to the group's default valid option, update the price, and announce it in text: "Color changed to Navy: Mustard is not available in velvet." The state-machines lesson's idea applies: the configuration is never allowed to sit in an invalid state, even for one render.
Prices on the client are a preview: base price plus the sum of deltas. The cart asks the server to price the configuration, because promotions, regions and taxes live there. The SKU or build code comes from the schema's pattern, for example SOF-3S-VEL-NAVY-OAK, which the order system maps to manufacturing.
Pick options. Try velvet while mustard is chosen, or the chaise with metal legs.
Preloading the likely next option
A 500 ms mesh swap feels slow, so after the first view settles the viewer uses idle time to prefetch with fetch and priority: "low": swatch images for all fabrics (tiny), the material files of the 3 most chosen fabrics from analytics, and the part file behind any swatch the shopper hovers or focuses for 150 ms. Prefetched files go to the HTTP cache, not the GPU, so they cost bytes but not GPU memory until chosen. On light tier, only hover and focus prefetching runs.
Check your understanding · Exercise 5
Which of these belong in the product schema rather than in the configurator code? Select all that apply.
Interaction: a camera shoppers cannot lose
Free 3D cameras are disorienting. A naive orbit control lets the shopper spin under the floor, zoom inside a cushion, or pan the sofa off screen, then they have no idea how to get back. A product camera is a constrained orbit: it circles a fixed target at the product's center, and every degree of freedom has limits set per product in the schema.
- Rotate: drag with one finger or the mouse. Horizontal is unlimited; vertical is clamped between about 10 and 85 degrees from straight up (
minPolarAngleandmaxPolarAnglein three.jsOrbitControls), so the camera never goes under the floor. - Zoom: pinch or wheel, clamped between a distance that fits the whole product and one that shows the fabric weave (
minDistance,maxDistance). The wheel only zooms after the viewer has focus, so scrolling the page past it does not get captured. - Pan: off by default. Products are framed by the pipeline, and pan is the control that loses people.
- Touch and page scroll: on phones a one-finger vertical drag over the viewer should still scroll the page. We set
touch-action: pan-yon the canvas, so the browser keeps vertical scrolling, and use horizontal drags to rotate. Inside a full-screen view, all gestures go to the camera. - Reset: a visible "Reset view" button and the
Homekey return to the default camera, so getting lost is never permanent.
Hotspots anchored to 3D points
A hotspot ("Removable covers") is a real HTML button positioned over the canvas, not pixels drawn in WebGL, so it is focusable, readable by screen readers, and styled like the rest of the page. Each hotspot has a 3D position and a surface normal in the schema. After every rendered frame, position.clone().project(camera) gives coordinates from -1 to 1, which we map to CSS pixels and apply with a transform. If the normal faces away from the camera, the hotspot fades out, so a label for the back of the sofa does not float over the front.
vector.project(camera) gives -1 to 1 on both axes. Map x to (x + 1) / 2 * width and y to (1 - y) / 2 * height, then set a transform on the button. Only write, never measure, in the frame.Because we render on demand, projection runs only when a frame renders, which is exactly when hotspots could have moved. Writing a transform on 5 buttons is cheap; reading layout in the same loop would not be, so we never measure DOM inside it.
Keyboard and reduced motion
The viewer is one focusable element with a label ("3D view of the Harlow sofa, use arrow keys to rotate"): arrow keys rotate by 15 degrees, + and - zoom, Home resets, and Tab moves to the hotspots in a sensible order (the keyboard-navigation lesson covers focus order and visible focus styles). When the shopper has prefers-reduced-motion: reduce, there is no auto-rotate, no camera flights between hotspots (the camera jumps), and no material crossfades. Auto-rotate is off by default anyway, because a spinning product competes with the text the shopper is reading.
Check your understanding · Exercise 7
Which choices keep shoppers from getting lost or stuck in the viewer? Select all that apply.
AR handoff: the configured sofa in the living room
AR answers the question photos cannot: will it fit, and does the green work with my rug? On the web, AR is mostly a handoff: the page passes a model file to the operating system's AR viewer, which handles the camera, floor detection and real scale. There are three paths, one per platform, and they need different files.
| Path | Where | File it needs | How the page starts it |
|---|---|---|---|
| Scene Viewer | Android, on ARCore supported devices (Android 7.0+, with Google Play Services for AR) | GLB (glTF 2.0) | An intent://arvr.google.com/scene-viewer/1.0?file=... link that opens the Google app's viewer. |
| AR Quick Look | iOS and iPadOS Safari | USDZ (or Apple's Reality format), not glTF | A link with rel="ar" wrapping an img, pointing at the USDZ file. |
WebXR immersive-ar | Chrome on Android with ARCore; not Safari on iOS | Anything the page can render | navigator.xr.requestSession("immersive-ar") and the page draws the AR scene itself. |
Decision Which AR path should the configurator use?
What we pick: Native viewers first, WebXR as an extra on Android where it is available. model-viewer wraps exactly this with its ar-modes attribute, default webxr scene-viewer quick-look in that priority. The hard part is not launching AR, it is making sure AR shows the configured sofa and not the default.
AR must show the configured variant
The shopper picked velvet, navy and metal legs. If AR opens the default grey linen sofa, we have taught them not to trust the feature. There are three ways to get files for the configured build, in order of preference.
- Server export. The render service exports a merged GLB and USDZ per build code, cached on the CDN like the snapshot. The AR button links to them; a build nobody has asked for yet waits a moment on first use.
- Client export. three.js has
GLTFExporterandUSDZExporter, andmodel-viewergenerates a USDZ on the fly whenquick-lookis enabled without anios-src. It needs no server, but it costs memory and CPU on the phone at the moment of the tap. - Pre-built files per combination. Only practical for products with a handful of combinations.
We use server export with client export as the fallback. Whichever path builds the file, it must be at real-world scale in meters, the unit glTF uses, so the sofa lands in the room at its true size.
Check your understanding · Exercise 8
Which statements about AR handoff are true? Select all that apply.
Architecture, data model, accessibility and failure
The key boundary is between configuring and viewing. The option panel, the rules engine, the price and the URL work with no 3D at all; they are server-rendered and hydrated like any product page form. The 3D viewer is a lazily loaded chunk that subscribes to the configuration and renders it. If the viewer never loads or crashes, configuring and buying still work, which gives us the fallback tier, accessibility and SEO almost for free.
| Part | Responsibility | Loaded |
|---|---|---|
| Option panel and summary | Real form controls per group, price, a text description of the build, a live region for changes. | With the page (server-rendered). |
| Configuration store | Current picks; derives option states and price with the rules engine; syncs the URL. | With the page, a few KB. |
| Rules engine | Pure function from schema and picks to option states, repairs and price. Shared with the server. | With the page. |
| Viewer shell | Tier detection, the viewer state machine, poster swap, fallback to the 360 spin. | With the page, under 2 KB. |
| Scene manager | three.js renderer, slots, render on demand, LOD upgrades, disposal, context loss. | Lazy chunk. |
| Asset loader | GLTFLoader with Meshopt and KTX2 decoders in workers, caches, abort on leave. | Lazy chunk. |
| Hotspot layer, AR launcher, snapshot client | DOM buttons over the canvas; AR links per build code; cart image. | Lazy chunk, AR on demand. |
Data flows one way. A pick updates the store; the store derives the new state and writes the URL; the panel re-renders from the store; the viewer receives the new picks and works out the difference from what it shows (which slots changed, material or mesh), loads what is missing, swaps, and requests one frame. The viewer never writes to the store except to report its own status (loading a part, ready, failed), which the panel shows as a small hint on the swatch.
// URL and store: what the shopper chose
type Configuration = { productId: string; version: number; picks: Record<string, string> };
// Derived on every change, never stored
type Derived = {
options: Record<string, { state: "allowed" | "disabled"; reason?: string }>;
repaired: { group: string; from: string; to: string; reason: string }[];
pricePreview: number;
buildCode: string;
description: string; // "3-seat sofa in navy velvet with oak legs"
};
// Viewer: memory only, per page
type SlotState = { slot: string; shown: string; lod: 0 | 1 | 2; status: "ready" | "loading" | "failed" };
// Component interface
type ConfiguratorProps = {
schema: ProductSchema;
initial: Configuration; // parsed from the URL on the server
onAddToCart: (config: Configuration) => Promise<CartLine>;
};
GET /api/products/harlow/schema?v=7 -> ProductSchema (CDN, immutable per version)
GET https://cdn.example.com/3d/harlow/v7/base-lod2.glb (immutable, 1 year)
GET https://cdn.example.com/3d/materials/velvet-navy.glb (shared by all products)
POST /api/cart/lines
{ "productId": "harlow", "version": 7, "picks": { "frame": "3s", "fabric": "vel", "color": "navy", "legs": "oak" } }
-> 201 { "lineId": "l_81", "buildCode": "SOF-3S-VEL-NAVY-OAK", "price": 189900,
"imageUrl": "https://cdn.example.com/render/SOF-3S-VEL-NAVY-OAK.webp" }
-> 409 { "error": "not-buildable", "repair": { "color": "navy" } }
GET /ar/SOF-3S-VEL-NAVY-OAK.usdz | .glb -> rendered on first use, then cached
Check your understanding · Exercise 9
Why is the option panel server-rendered and independent of the 3D viewer? Select all that apply.
Nothing only in 3D
A canvas is opaque to assistive technology: a screen reader announces it, at best, as one image. So the 3D view is a visual extra, and every fact and every action exists in HTML too. Each option group is a fieldset with a legend and native radio inputs (the aria-patterns lesson explains why native controls beat custom roles).
Each swatch has a text name and its price change, "Velvet, plus 200 dollars", so color is never the only cue (see visual-accessibility). A disallowed option stays focusable with aria-disabled="true" and its reason linked by aria-describedby, and the change handler ignores it. A natively disabled radio would be skipped by the keyboard, and the shopper would never learn why it is unavailable.
The summary holds the description from the rules engine ("3-seat sofa in navy velvet with oak legs, 1,899 dollars"), and a polite live region announces each change and each repair. The canvas element itself gets role="img" and an aria-label with the same description, kept in sync; not the wrapper, because an image role would hide the hotspot buttons inside it. Hotspot facts also appear in a features list below, and dimensions, which shoppers often look for in 3D or AR, are written in text.
GPU memory: dispose what you replace
three.js does not free GPU memory when you remove an object from the scene: geometries, materials and textures hold GPU buffers until you call dispose() on each. Swapping fabrics without disposing leaks about 2 to 16 MB per swap (three 1024 maps, as KTX2 or RGBA), and a few dozen swatch clicks can get the tab killed. iOS Safari is the strictest: WebKit publishes no limit, but a tab using a few hundred MB of GPU and JavaScript memory on an older iPhone is at risk (an estimate; test on real devices).
- Keep a small LRU cache of material sets on the GPU: the current fabric plus the last two, so flipping between two favorites is instant. Evicted sets are disposed; their files stay in the HTTP cache for a fast return.
- Dispose replaced part meshes and their unique textures; never dispose shared ones still in use (the environment map, the floor shadow). Count references per texture.
- On leaving the product (client-side navigation), dispose everything and call
renderer.dispose(). The memory-leaks lesson shows how to confirm with heap snapshots;renderer.info.memorycounts live geometries and textures, which a test can assert.
When the GPU context is lost
The browser can take the WebGL context away at any time: a GPU driver reset, too many contexts, or the OS reclaiming memory from a background tab. The canvas fires webglcontextlost, and if the handler calls event.preventDefault(), the browser may later fire webglcontextrestored, after which every buffer and texture must be uploaded again. We test this path with the WEBGL_lose_context extension, which simulates the loss on demand.
Our viewer then moves to its context lost state, shows the poster of the current build (the server snapshot, so it matches), and pauses requests. On restore, it rebuilds the scene from the HTTP cache. If restore does not come within a few seconds, or the context is lost twice in a minute, it stays on the 360 spin fallback.
Field data closes the loop: report the tier chosen, time from trigger to first 3D frame, LOD reached, option change latency, context losses and fallbacks, split by device class. Those numbers tell us whether the budgets and tier thresholds are right, which lab tests on a fast laptop never will.
Check your understanding · Exercise 10
A shopper on an iPhone taps through 25 fabrics and the tab reloads itself. What are likely causes or fixes? Select all that apply.
Running it in the interview
This problem tempts candidates to talk about shaders for 40 minutes. Interviewers for a frontend role want the product page view: budgets, loading, data, failure and access. A plan for 45 to 60 minutes:
- Minutes 0 to 5, scope. Ask about devices, which options change shape, rules, AR, and what the cart needs. State the two budgets: download for first view and GPU memory, set by a mid-range phone.
- Minutes 5 to 12, the asset pipeline. glTF and GLB, KTX2 for GPU memory, Meshopt or Draco for geometry, LODs, a budget check in CI. Do the 4096 texture math out loud: about 85 MB as RGBA, about 21 MB as ASTC 4x4, both with mipmaps.
- Minutes 12 to 20, loading. Poster as LCP, tiers, load when visible and idle, LOD2 first, image spin fallback. Draw the viewer state machine.
- Minutes 20 to 32, configuration. The schema, material versus mesh swaps, the rules engine shared with the server, conflicts and repair, URL encoding, cart line, snapshot service.
- Minutes 32 to 40, architecture. Draw the split between the server-rendered option panel and the lazy viewer, and the one-way data flow.
- Remaining time, depth on request. Render on demand, disposal and context loss, AR with the configured variant, accessibility.
Say the numbers as you go: 64 MB for one raw 4K texture, about 2 MB for the first view, a 2x pixel ratio cap, a 150 MB GPU budget. Numbers turn "it should be fast" into decisions the interviewer can push on.
Think first
The interviewer moves you to a car: 10,000 valid combinations, and the paint must look perfect. What changes in your design?
The structure holds: schema, rules engine, URL, base model plus parts and materials. What changes is quality and weight. Car paint needs clear-coat materials (glTF has KHR_materials_clearcoat) and better lighting, so the full tier budget grows and the light tier leans more on server renders.
Many car configurators stream server-rendered images or video for the hero view and keep real-time 3D for interior and rotation. With rules this complex, the rules engine may move to a server endpoint, with the client caching answers per partial configuration.
Think first
Why not just use the model-viewer web component?
For many stores, use it: it gives a poster, lazy loading, camera controls, hotspots, variants and AR handoff in one element, and Shopify uses it for 3D product media. It is a fine answer if the options are mostly material variants inside one file.
We outgrow it when parts are separate files attached to slots, when we need custom LOD streaming, an LRU of fabrics, or a shared rules engine driving the scene. Then we build on three.js directly, which is what model-viewer uses too, and keep its ideas.
Summary
- Budget two things separately: download bytes for the first view (about 2 MB, under a 5 MB ceiling) and GPU memory (under about 150 MB), both set by a mid-range phone.
- A build pipeline turns raw exports into GLB with Meshopt geometry, KTX2 textures and three LODs, and fails any product over budget. With mipmaps, a 4096 texture is about 85 MB as RGBA and about 21 MB as ASTC 4x4.
- The poster image is the LCP element. The 3D runtime loads when the viewer is visible and the page is idle, only on capable devices; others get it on tap or keep a 360 degree image spin.
- Render on demand and cap the pixel ratio. A still product should cost zero GPU work.
- The product is data: groups, options, rules, price deltas, SKU patterns, and whether each option swaps a material or a mesh. One rules engine runs on the client and the server.
- The configuration lives in the URL, the cart line and the order, as short stable option ids. A server render keyed by build code supplies cart, email and AR files.
- Configuring works without 3D: real form controls, a text description, a live region. The canvas is a visual extra, never the only source of information.
- Dispose replaced geometries and textures, keep a small LRU of materials, and handle context loss by falling back to the poster.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- glTF 2.0 specification (Khronos) The format, PBR materials, units in meters.
- KHR_texture_basisu extension (Khronos) KTX2 with Basis Universal textures in glTF.
- EXT_meshopt_compression extension Geometry compression with a fast decoder.
- KHR_materials_variants extension (Khronos) Named material sets in one file.
- three.js manual: how to dispose of objects Why removing from the scene is not enough.
- model-viewer documentation Poster, loading, reveal, variants, hotspots and AR modes.
- Scene Viewer (Google ARCore) Android AR handoff with glTF.
- WebGPU is now supported in major browsers (web.dev) Current WebGPU availability by browser and OS.
- KHR_draco_mesh_compression extension (Khronos) Draco geometry compression in glTF.
- Largest Contentful Paint (web.dev) Which elements count as LCP candidates; a canvas does not.
- Viewing AR assets in Safari for iOS (WebKit) USDZ and the rel="ar" link for AR Quick Look.
- WebGL context lost event (MDN) Handling and restoring a lost context.
Concepts behind it
- Loading strategiesDeciding what the browser loads first, what it loads later, and what it loads only when needed, so the pixels that matter show up fast.
- Images and mediaSending each user an image of the right size and format, reserving its space, and loading it at the right moment: images are usually the largest share of a page's bytes and its LCP element.
- Core Web VitalsThree numbers, measured on real users, that say whether a page loads fast (LCP), reacts fast (INP), and stays still (CLS): what each one measures, what makes it bad, and how to fix it.
- Runtime performanceKeeping the page responsive after it loads: finding long tasks on the main thread, splitting or moving that work, and avoiding needless re-renders so every click and keypress paints in under 200 ms.
- The URL as stateFilters, sort, page, tab, and selected item stored in the address bar instead of only in memory, so a view can be shared, bookmarked, refreshed, and walked back with the Back button: the cheapest state store the browser gives you.
- Config-driven UIDescribing screens as JSON that names known components, rendered through an allowlisted registry, so layouts change without an app release: safe only if configs are validated, never executed, and older clients get fallbacks.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Memory in long-lived appsWhy single-page apps that stay open for hours fill up memory, how the garbage collector decides what to keep, the classic leaks, and how to find them with heap snapshots and keep them out with budgets.
- State machines for UIModel a flow as a fixed set of states and the events that move between them, so impossible combinations like "loading and failed at once" can't happen and every button does the right thing in every state.
Try next
- Design an e-commerce site like AmazonHard
- Design an e-commerce cart and checkoutMedium
- Design a checkout flowHard
- Design an image carouselMedium
- Design an image lightbox with zoomMedium
- Design a web photo editorHard
- Design a collaborative design tool like FigmaHard
- Design a whiteboard like ExcalidrawHard
- Design Google MapsHard