Projects

3D Watch Configurator

Customize a wristwatch in real time: swap case, strap, and dial materials on a fully rendered 3D model.

A real-time 3D product configurator built with React Three Fiber and Three.js. Load a detailed wristwatch model, swap case, strap, and dial materials with live color updates, orbit the camera freely, and watch every change apply instantly under studio lighting, all in the browser.

Built as a client-facing demo to prove real-world product-visualization capability: the kind of piece a founder or client understands in ten seconds. Solo project, open source: one Timex Expedition model with 18 material slots, three configurable groups, and a responsive UI that collapses to a bottom sheet on mobile. Deployed live at watch-configurator.anjana784.dev.

The challenge

Make product customization feel immediate. The user clicks a swatch and the material changes on the model, with no loading spinner, no flicker, no frame drop. The watch has to read as a real object: metal catches light, leather shows grain, glass reflects the environment. And the interaction needs to work identically on a phone held in one hand: touch drag to rotate, pinch to zoom, and swatches in a bottom sheet you can reach with your thumb.

The model itself is 101k triangles, sourced from Sketchfab (CC-BY) rather than modeled from scratch, because the timeline was one week and the point was the configurator, not the asset. That triangle count is fine for desktop, but mobile load times needed Draco compression to stay within the 2–3 second target.

The tricky part was material isolation. The GLB had 18 named material slots, and the configurable parts (case chrome, leather strap, dial plastic) were already separated. But some slots like chrom_plastik covered both the case body and the second hand's white body in the same material, so swapping case color tinted the chronograph hand too. Splitting that would need a Blender edit, and the timeline said no. The trade-off was documented and accepted.

The approach

Separate the concerns: model loading, material swapping, camera controls, and the UI layer each own one thing. The config data is a typed object with three option groups (Case, Strap, Dial), each holding a list of material slot names and color options. The material-swap logic clones the configured slots off the shared GLTF cache and recolors the clones, so the cache stays pristine and React re-renders the meshes reactively when the cloned map changes identity.

The camera uses drei's OrbitControls wrapped in a custom auto-rotate controller: a 600ms delay before rotation starts (so the hero angle reads first), a damped speed ramp for smooth start/stop, and a 3.5-second idle timeout before resuming after the user stops dragging. Pan is disabled, distance is clamped, and the rotation speed eases in and out with MathUtils.damp, never snapping.

The UI layer is two layouts behind one component API: a floating semi-transparent panel on desktop (right side, vertically centered) and a Radix accordion bottom sheet on mobile. The swatch buttons are circular color swatches with a gold accent ring (#C29A2E) on the selected option: visual selection, not dropdowns.

Technical details

Material swapping

The createConfiguredMaterials function takes the base materials from the GLTF cache and the current selection, clones only the slots mapped to configurable groups, and sets their color. The returned Partial<Record<MaterialSlot, MeshStandardMaterial>> map changes identity on every selection change, so the Watch component's meshes pick up the new materials through R3F's reactive props. Materials not in any group (glass, indices, the orange accent hand, the Timex logo) are never touched.

Auto-rotate with damped speed ramp

Rather than toggling autoRotate on and off (which produces a visible snap), the controller drives autoRotateSpeed through MathUtils.damp with separate lambdas for ramp-up (λ=4, ~1s ease-in) and ramp-down (λ=8, faster interruption). A START_DELAY_MS of 600ms holds rotation after mount so the hero camera angle (a ¾ view showing case, dial, and a hint of strap) reads first. After the user stops dragging, an idle timer of 3.5 seconds triggers resume, with the speed ramp making the return to auto-rotation seamless.

Loading state

Instead of a generic spinner, the loading screen renders an SVG chronograph face: a white-on-dark watch dial with 12 hour markers, a gold second hand that ticks once per second (6° steps with a smooth CSS transition), and a progress percentage. It overlays the Canvas until useProgress reports the model and textures are ready, with no flash of unstyled content.

Responsive swatch panel

Desktop gets a floating panel (absolute right-5 top-1/2 -translate-y-1/2) with swatches in a two-column grid beside their labels. Mobile gets a Radix accordion anchored to the bottom of the viewport: collapsed, it shows a slim "Customize" bar with the current config summary; expanded, it reveals all three groups stacked vertically with horizontally scrollable swatch rows. The accordion height animation uses a CSS grid-rows trick (grid-rows-[0fr]grid-rows-[1fr]) inside a wrapper div. Radix's content-measuring layout effect force-disables transitions on the Content element itself, so the animation lives on an inner wrapper toggled by the group's data-state.

Lighting

A single warehouse HDRI environment at 0.77 intensity provides the studio look: neutral enough to let metal, leather, and glass each read correctly, with enough contrast to make the watch pop against the dark #0D0D0D background. No additional lights; the environment alone handles key, fill, and reflections.

Outcome

Shipped live at watch-configurator.anjana784.dev in August 2026, open source, with the full configurator-options schema and material-swap architecture documented in the README.

The configurator loads a 101k-triangle watch model, renders it under studio HDRI lighting, and lets you swap case (silver/gold/rose gold/black), strap (brown/black/tan/navy), and dial (green/navy/black/sand) materials with instant visual feedback. Orbit controls work via mouse drag and touch, zoom via scroll and pinch, with damped auto-rotation that pauses on interaction and resumes gracefully.

The core deliverable is the material-swap architecture itself: clone from cache, don't mutate, so the model can be re-mounted or reused without side effects, and the config data is a plain typed object that could drive a server-rendered variant picker or a CMS-backed product catalog with no changes to the rendering layer.

Reflection

Cloning materials from the GLTF cache rather than mutating them in place was the right call. It cost one extra allocation per swap and bought complete isolation from the cache, so the model can be re-mounted, hot-reloaded, or instanced without pollution. The damped speed ramp on auto-rotate is the smallest piece of code in the project and the one that makes the camera feel polished rather than robotic.

The chrom_plastik slot covering both the case body and the second hand body is a known compromise; splitting it needs a Blender pass on the source model. For a v1 built in a week, documenting it was the right call; for a client deliverable, I'd do the Blender split.

Draco compression on the GLB was essential. The raw model is heavy for mobile, and the compression pass brought it within the 2–3 second load target. If I were building a multi-product version, I'd add a compression step to the asset pipeline rather than running gltfjsx manually per model.

The Radix accordion pattern for the mobile bottom sheet, with the height animation decoupled from Radix's content measurement via the inner grid-rows wrapper, is now my default pattern for collapsible panels in React. It avoids fighting the library's layout measurement while keeping the animation smooth.