Shaun Seidman
Engineering Manager · Denver, CO
GitHub ↗ LinkedIn ↗

03 / note · July 20, 2026

Building things I can't afford with React Three Fiber

Six virtual watches, one shared clock, and more moving parts than I planned for.

When I was a kid, I wanted a Tech Deck Halfpipe but my mom wouldn't get me one - so I built one out of cardboard (sadly no pictures available). As an adult, I've become a self proclaimed horologist and want quite a few nice watches that are WAY out of my tax bracket. Not because of the name/branding behind them, but because of the history of the movements and their lineage. Over the past month or so, I've been working on a passion project called Case to Calibre, a 3D watch workshop where you can rotate a watch, explode it into layers, inspect the movement, and click through the parts.

Case to Calibre has become my collection of 'cardboard' watches. I can pull them apart, play with the movements, and avoid explaining another watch purchase to my wife.

The project is up to six watches now. The movements run, the lume glows, the chronograph records time, and the tourbillon carriage makes a trip around the dial once a minute. I did not use imported watch models, so Three.js builds every case, gear, bridge, hand, and dial in the browser.

React Three Fiber gave me a React-shaped way to build all of it. That part felt familiar. Learning which work belonged in React and which work needed to happen inside the render loop took me a bit longer.

Keep animation in useFrame

A watch has a stupid number of moving parts. My first instinct was to put the clock in React state and let the components update from there. That would ask React to schedule a render for every tick, across every moving part, at the refresh rate of the display. No thanks.

Fiber gives each component access to useFrame. I use it to mutate refs and shared clock values inside the render loop while React handles the parts that belong on screen.

The clock driver ended up looking like this, minus some chronograph code that nobody needs to read twice:

function ClockDriver() {
  const { clock, movement } = useWatch();

  useFrame((_, dt) => {
    const state = useViewerStore.getState();
    const scale = TIME_SCALES[state.timeMode];
    const elapsed = Math.min(dt, 0.1) * scale;
    const powered = movement.vph === 0 || clock.powerReserveSeconds > 0;

    if (powered) {
      clock.t += elapsed;
      if (movement.vph > 0) {
        clock.powerReserveSeconds = Math.max(
          0,
          clock.powerReserveSeconds - elapsed
        );
      }
    }
  });

  return null;
}

I cap dt because browsers do weird things with tabs that sit in the background. Without the cap, returning to the tab could jump the movement forward by a huge amount on the next frame. It is a fun way to make a mechanical watch look possessed.

I also read the Zustand store with getState() inside the loop. The controls still update through React, but the frame loop can grab the current value without causing another component render. The Fiber performance guide recommends the same pattern: mutate inside useFrame, use the frame delta, and keep setState out of fast loops.

One clock should drive the whole movement

Mechanical watches make timing bugs easy to spot. Give the seconds hand, fourth wheel, and escapement separate timers and they will disagree sooner than you would hope. Then you get to stare at the movement and wonder which gear is lying to you.

I store one mutable time value and make every moving part derive its angle from that:

export function handAngles(t: number) {
  const beats = easedBeats(t);

  return {
    seconds: -(beats / (60 * BEATS_PER_SECOND)) * Math.PI * 2,
    minutes: -((t % 3600) / 3600) * Math.PI * 2,
    hours: -((t % 43200) / 43200) * Math.PI * 2,
    gmt: -((t % 86400) / 86400) * Math.PI * 2,
  };
}

That same t drives the gear train, date wheel, power reserve, and tourbillon. A 28,800 VPH movement beats eight times per second, so I use that count for the stepped seconds hand and escape wheel. The slower wheels come from the elapsed time.

The single clock also made slow motion and pause much less annoying. I change the scale applied to dt and the whole movement follows along. I do not have to pause a pile of separate animation instances and hope they all come back in sync.

The code started to make more sense once I treated the movement like a movement. The center wheel turns once an hour, the fourth wheel once a minute, and the balance runs at 4 Hz. I put those relationships into functions and let the shared clock feed them. Fewer mystery numbers, fewer gears wandering off on their own.

Put shared behavior around every part

I did not want six watches worth of one-off interaction code, so each part gets a small definition:

{
  id: "crystal",
  name: "Sapphire crystal",
  group: "case",
  layer: 0,
  explode: [0, 0, 30]
}

PartMesh reads that registry and handles the exploded view, inspector, selection, and x-ray behavior. I wrap each piece of geometry with it and get the same rules across the whole collection.

useFrame((_, dt) => {
  const state = useViewerStore.getState();
  const k = 1 - Math.exp(-Math.min(dt, 0.1) * 9);
  const t = layerExplodeT(state.explode, part.layer);

  group.current.position.x +=
    (part.explode[0] * t - group.current.position.x) * k;
  group.current.position.y +=
    (part.explode[1] * t - group.current.position.y) * k;
  group.current.position.z +=
    (part.explode[2] * t - group.current.position.z) * k;
});

The damping uses dt, so the transition feels the same on a 60 Hz laptop and a 120 Hz phone. The layer function gives each part its own section of the slider range. Move the slider and the crystal lifts first, followed by the hands, dial, movement, and caseback.

This paid off as soon as I moved past the first watch. I can add a part to the manifest, wrap its mesh with PartMesh, and get the inspector behavior without rebuilding the interaction system each time. I have enough watch parts to model without giving each one a custom click handler too.

Orbiting and clicking share the same surface

Fiber pointer events feel a lot like DOM events until OrbitControls gets involved. I would drag across the watch to rotate the camera, release over a gear, and select the gear by accident. The app saw a click. I saw a camera move followed by an inspector I did not ask for.

Fiber includes the pointer travel distance on the event, so I added a small tolerance before treating the release as a selection:

const CLICK_DRAG_TOLERANCE = 3;

onClick={(event) => {
  event.stopPropagation();
  if (event.delta > CLICK_DRAG_TOLERANCE) return;
  useViewerStore.getState().select(partId);
}}

The explode control had a cousin of that bug. A selected part stayed selected after I returned the watch to its assembled position, leaving everything else dimmed. Sliding back to zero now clears the selection and hover state.

Both fixes took a few lines. Figuring out those lines meant watching the whole gesture instead of staring at the final pointer event.

There is no z-index for a watch movement

One commit in the repo is named tank zindex bug, which tells you where my head was at the time.

I moved the quartz module forward during x-ray mode so I could see it through the dial. It looked fine from one camera angle and broke from another because I had changed the physical stack of the watch to solve a visibility problem.

I removed the translation and used opacity, depth writing, and the real part positions instead.

Transparent 3D materials are fussier than a translucent DOM element. Case to Calibre changes transparent and depthWrite as parts fade:

material.opacity += (targetOpacity - material.opacity) * k;

const fading = material.opacity < 0.995;
material.transparent = fading || baseTransparent;
material.depthWrite = fading
  ? material.opacity > 0.55 && baseDepthWrite
  : baseDepthWrite;

I still use renderOrder for a few surfaces, but it cannot rescue bad geometry or the wrong material settings. If the camera angle changes and the whole trick falls apart, I did not fix the problem.

The canvas needs a budget

The homepage and gallery show several watches at once, and six tiny WebGL scenes can get expensive fast. Those previews render the watch head without raycasting, camera controls, or post-processing. I also lock their device pixel ratio to 1. The heavier pieces wait until someone opens the inspector.

<Canvas dpr={mode === "preview" ? 1 : [1, 2]}>
  <SceneStage />
  {inspector ? <Watch /> : <Watch headOnly />}
  {inspector && <CameraRig />}
  <AdaptiveDpr pixelated />
</Canvas>

Next.js loads the canvas through a client-only dynamic import. WebGL stays out of server rendering, and I get a place to show a loading state while the scene code arrives.

My favorite browser bug showed up when the site opened in a background tab. requestAnimationFrame did not run, ResizeObserver did not deliver the first useful measurement, and the Fiber canvas sat there waiting for a size. I added a hidden-tab fallback with a timer and an initial measurement. Turns out a 3D watch can still get taken down by a div with no dimensions.

Fiber still requires Three.js knowledge

React Three Fiber let me organize the scene graph with components, context, and typed props. That works well for watches because they share so many systems. Cases change, movements change, but most of the interaction model can stick around.

I still had to learn the Three.js side of the deal: meshes, materials, camera movement, transparent surfaces, and frame timing. Fiber did not hide any of that, and I am glad it did not. I keep the product structure and controls in React, then reach through Fiber to mutate a mesh or read the current scene state when I need to.

I added a tourbillon anyway, so I have learned nothing about keeping the moving-part count under control.