Library / Tutorial

Scroll-to-assemble watch,
step by step.

A product page where scroll assembles a mechanical watch modelled in code, part by part: each part flies home and locks with a glint, the movement starts to beat, and the finished watch runs on your local time. One scroll progress drives all of it. Six stages, each a live page you can run.

6Stages
25Parts
1Motor

This is the whole build of the scroll-to-assemble watch, in the order it was made. A product page for a made-up maison, ATELIER H., where scrolling assembles the Calibre AH-01 one part at a time: the strap, the plate, the gear train, the escapement, the balance, the bridges, the rotor, the dial, the hands and the crystal. Every part flies home on its own arc and locks with a glint; once the balance cock is on, the movement beats, and the finished watch runs on your local time. It is plain HTML, CSS and JavaScript plus three.js, with no framework and no build step, and nothing in the 3D is an image: the watch is modelled in code. Every stage below is a working page: look at the still, or run it right here.

How to read this

Each code block is copied from the stage it belongs to, unchanged. A … marks lines left out for space. The last stage is byte for byte the finished page, so the download at the end is exactly what you see on the demo.

1 · The page

Start without any 3D and without any JavaScript. The page is a fixed nav, one very tall section and, inside it, a sticky stage the height of the screen. While you scroll through the section the stage stays put: that is the track scroll will drive in stage 4.

index.html — the head
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#050403">
<title>ATELIER H. · Scroll to assemble — step 1</title>
<meta name="description" content="Scroll to assemble the ATELIER H. Calibre AH-01, part by part. A fictional maison; the watch is modelled in code.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&family=Lato:wght@300;400;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/watch.css">
index.html — the build section
<section class="build" id="build" aria-labelledby="headline">
  <div class="stage">
    <div class="velvet" aria-hidden="true"><div class="spot"></div><div class="nap"></div></div>
    <picture><source media="(max-aspect-ratio: 82/100)" srcset="assets/renders/phone_start.webp"><img class="fallback" id="fallback" src="assets/renders/hero_start.webp" width="2160" height="1350" alt="An ATELIER H. watch in exploded view: dial, hands, rotor and movement parts floating apart from the case."></picture>
    <div class="copy">
      <h1 id="headline"><span>Scroll to</span> <span>assemble</span> <span>your legacy.</span> <span>Atelier H.</span></h1>
    </div>
  </div>
</section>

The page is dark on purpose and never follows the OS colour scheme: color-scheme: dark, a dark theme-color, and no prefers-color-scheme rule anywhere. The palette comes from the board: velvet black, a warm rose gold for accents, and off-white ink.

watch.css — tokens
:root {
  color-scheme: dark;
  --ink: #f4f1ec;
  --ink-2: rgba(244, 241, 236, .62);
  --ink-3: rgba(244, 241, 236, .38);
  --velvet: #050403;
  --rose: #e2c3a2;
  --rose-2: #c9a27c;
  --rule: #2a2622;
  --focus: #f0d2b0;
  --pad: clamp(16px, 2.2vw, 40px);
  --nav-h: 72px;
  --tiles-h: min(22vh, 230px);
  --serif-free: 'Lato', 'Helvetica Neue', Arial, sans-serif;
  --ui: 'Inter', 'Helvetica Neue', Arial, sans-serif;
}

The velvet is two layers of CSS. A warm spotlight falls from the top right and drifts very slowly (a 19 s transform and opacity animation, so it stays on the compositor), and a fine SVG noise lies over it in soft light, so the black reads as cloth rather than a flat fill.

watch.css — one tall section, a sticky stage, the velvet
.build { position: relative; height: 560vh; }
.stage { position: sticky; top: 0; height: 100vh; height: 100svh; overflow: hidden; }
.velvet { position: absolute; inset: 0; background: radial-gradient(120% 95% at 30% 62%, #0b0907 0%, #040302 72%); }
.velvet .spot { position: absolute; width: 95vw; height: 120vh; right: -28vw; top: -46vh; border-radius: 50%;
  background: radial-gradient(closest-side, rgba(232, 206, 172, .78), rgba(178, 140, 100, .4) 40%, rgba(96, 70, 44, .14) 70%, rgba(0, 0, 0, 0) 100%);
  transform-origin: 70% 30%; animation: drift 19s ease-in-out infinite alternate; will-change: transform, opacity; }
.velvet .nap { position: absolute; inset: 0; opacity: .5; mix-blend-mode: soft-light;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 .5 0 0 0 0 .45 0 0 0 0 .4 0 0 0 .55 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); }
@keyframes drift { from { transform: translate3d(0, 0, 0) scale(1); opacity: .92; } to { transform: translate3d(-4vw, 3vh, 0) scale(1.07); opacity: 1; } }

The headline is four lines of uppercase Lato in the left column, sized from the screen height so it sits the same way on every desk screen. Until stage 2 a still of the exploded watch stands in for the 3D: a render of the finished stage, with the phone picture chosen by the same aspect rule the CSS uses.

watch.css — the copy column
.copy { position: absolute; left: 7vw; top: 0; bottom: 0; width: min(34vw, 520px); pointer-events: none; }
.copy > * { pointer-events: auto; }
h1 { position: absolute; left: 0; top: 27vh; margin: 0; font: 400 clamp(28px, 4.75vh, 62px)/1.07 var(--serif-free); letter-spacing: .01em; text-transform: uppercase; color: var(--ink); text-shadow: 0 2px 24px rgba(0, 0, 0, .6); }
h1 span { display: block; }

On a phone (any screen taller than it is wide, below a ratio of 0.82) the headline moves to the bottom, centred on three lines, and the search button leaves the nav.

watch.css — phone
@media (max-aspect-ratio: 82/100) {
  :root { --nav-h: 64px; }
  .nav { padding: 0 8px; }
  #search-btn { display: none; }
  .wm-main { font-size: 24px; }
  .wm-sub { font-size: 7.5px; }
  .copy { left: 16px; right: 16px; width: auto; }
  h1 { left: 0; right: 0; top: auto; bottom: calc(10vh + 34px); text-align: center; font-size: clamp(20px, 5.5vw, 34px); line-height: 1.18; }
  h1 span { display: inline; }
  h1 span:nth-child(2)::after { content: '\A'; white-space: pre; }
  h1 span:nth-child(3)::after { content: '\A'; white-space: pre; }
  …
}
Stage 1 · the pageOpen ↗
The ATELIER H. page without any 3D yet: the wordmark and the nav icons, SCROLL TO ASSEMBLE YOUR LEGACY. ATELIER H. on the left, and a still of the exploded watch on black velvet under a warm spotlight from the top right.

2 · The 3D studio

three.js r186 is vendored next to the page and loaded through an import map, so there is no CDN and no bundler.

index.html — the import map
<script type="importmap">{"imports":{"three":"./vendor/three/build/three.module.js","three/addons/":"./vendor/three/jsm/"}}</script>

The page waits for the dial font (with a time limit, so a slow network never blocks the 3D), loads the stage and sizes it to the sticky stage.

app.js — boot
async function boot() {
  // the dial print uses Lato: wait for it (bounded) so the canvas texture never falls back to a system face
  await Promise.race([Promise.all([document.fonts.load('400 40px Lato'), document.fonts.load('700 40px Lato')]), new Promise((r) => setTimeout(r, 1800))]);
  const { createStage } = await import('./stage.js');
  const qs = new URLSearchParams(location.search);
  stage = await createStage(canvas, { reduced, maxDpr: 2, centre: qs.get('frame') === 'centre' });
  const fit = () => { const r = canvas.parentElement.getBoundingClientRect(); stage.resize(r.width, r.height); };
  fit();
  stage.warm();
  new ResizeObserver(fit).observe(canvas.parentElement);
  resolveReady();
}

The renderer draws on a transparent canvas over the CSS velvet. Tone mapping and the colour space are left to our own output pass, and shadows are rendered only when asked.

stage.js — the renderer
const renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: true, premultipliedAlpha: true, powerPreference: 'high-performance', stencil: false });
renderer.setClearColor(0x000000, 0);
renderer.toneMapping = THREE.NoToneMapping;
renderer.outputColorSpace = THREE.LinearSRGBColorSpace;   // the output pass converts
renderer.shadowMap.enabled = !dbg.has('noshadow');
renderer.shadowMap.type = THREE.PCFShadowMap;
renderer.shadowMap.autoUpdate = false;

Polished metal shows nothing but its reflections, so the light that matters is the environment. It is a small photo studio built in code and baked once into a PMREM: a black room a touch lighter overhead, softboxes that fall off toward their edges, tall strips, a warm rim behind and a hard kicker for sparkle. Nothing is downloaded.

env.js — the studio
panel(10, 8, [11, 6, 4.5], [1.0, 0.9, 0.78], 2.4, true);        // big right softbox (beside the camera, off the dial's mirror line)
panel(10, 8, [6, 9.5, 3], [1.0, 0.8, 0.6], 5.0, true);          // warm key softbox, high right
panel(2.0, 14, [-9.5, 1.5, 4], [0.95, 0.97, 1.0], 4.0, true);   // tall strip, left
panel(16, 1.6, [0, 11, -0.5], [1.0, 0.95, 0.9], 2.2, true);     // overhead strip
panel(1.6, 12, [9, 0.5, -6.5], [1.0, 0.78, 0.55], 4.0, true);   // warm rim, behind right
panel(1.4, 1.4, [-4.5, 6, 9.5], [1.0, 1.0, 1.0], 3.5);          // kicker: small hard sparkle
panel(20, 7, [0, -9, 5], [0.55, 0.42, 0.3], 0.14);              // warm floor bounce
panel(10, 8, [-6, 2, -8], [0.6, 0.7, 1.0], 0.45, true);         // cool back fill
const pmrem = new THREE.PMREMGenerator(renderer);
const rt = pmrem.fromScene(scene, 0.0, 0.1, 100, { size: 512 });
pmrem.dispose();

On top of that, a warm spot from high on the right casts the only soft shadows (inside the case and under the bridges), and a cool rim light separates the steel from the velvet.

stage.js — key and rim
// key: a warm spot from high right, soft shadows inside the case and under the bridges
const key = new THREE.SpotLight(new THREE.Color().setRGB(1.0, 0.86, 0.7), 3.2, 0, 0.24, 0.9, 0);
key.position.set(230, 300, 260); key.target.position.set(0, 0, 0);
key.castShadow = true;
key.shadow.mapSize.set(1536, 1536);
key.shadow.camera.near = 250; key.shadow.camera.far = 620;
key.shadow.bias = -0.0004; key.shadow.normalBias = 0.03; key.shadow.radius = 3;
scene.add(key, key.target);
const rim = new THREE.DirectionalLight(new THREE.Color().setRGB(0.75, 0.82, 1.0), 0.9);
rim.position.set(-200, 120, -150);
scene.add(rim);

Every frame renders into a HalfFloat target with 4× MSAA. While nothing moves, each new frame is a sample jittered by a Halton sequence, blended into a running average, so a still converges to a supersampled picture. The brightest glints feed a four-level bloom.

stage.js — one sample
function renderSample(jitter, w) {
  const k = jitter ? st.samples : 0;
  const jx = jitter ? halton(k + 1, 2) - 0.5 : 0, jy = jitter ? halton(k + 1, 3) - 0.5 : 0;
  placeCamera(st.shown, jx, jy);
  renderer.setRenderTarget(rtScene); renderer.clear(); renderer.render(scene, camera);
  if (stopAt <= 1) return;
  // history: a running average (w = 1 starts it afresh, w = 1 / n adds the n-th sample)
  const prev = hist[histI], next = hist[1 - histI];
  blend.material.uniforms.tCur.value = rtScene.texture; blend.material.uniforms.tPrev.value = prev.texture; blend.material.uniforms.uW.value = w;
  renderer.setRenderTarget(next); blend.render(renderer);
  histI = 1 - histI; let src = next.texture;
  const shown = src;
  // … bloom: four half-size levels down and back up, then the output pass
}

The output pass applies ACES, converts to sRGB and adds a fine grain plus dither, weighted by the alpha, so the transparent velvet around the watch stays clean.

stage.js — the output pass
// ACES (Stephen Hill fit)
vec3 RRTODT( vec3 v ) { vec3 a = v * ( v + 0.0245786 ) - 0.000090537; vec3 b = v * ( 0.983729 * v + 0.4329510 ) + 0.238081; return a / b; }
vec3 aces( vec3 c ) {
  const mat3 I = mat3( 0.59719, 0.07600, 0.02840, 0.35458, 0.90834, 0.13383, 0.04823, 0.01566, 0.83777 );
  const mat3 O = mat3( 1.60475, -0.10208, -0.00327, -0.53108, 1.10813, -0.07276, -0.07367, -0.00605, 1.07602 );
  return clamp( O * RRTODT( I * c ), 0.0, 1.0 );
}
vec3 toSRGB( vec3 c ) { return mix( c * 12.92, 1.055 * pow( c, vec3( 1.0 / 2.4 ) ) - 0.055, step( 0.0031308, c ) ); }
float hash( vec2 p ) { vec3 p3 = fract( vec3( p.xyx ) * 0.1031 ); p3 += dot( p3, p3.yzx + 33.33 ); return fract( ( p3.x + p3.y ) * p3.z ); }
void main() {
  vec4 h = texture2D( tHist, vUv * uHistScale );
  vec3 b = texture2D( tBloom, vUv ).rgb;
  vec3 c = ( h.rgb + b * uBloom ) * uExposure;
  c = toSRGB( aces( c ) );
  float a = clamp( h.a, 0.0, 1.0 );
  float n = hash( gl_FragCoord.xy + uSeed * 61.0 ) - 0.5;
  c += ( n * uGrain * a + ( hash( gl_FragCoord.yx * 1.37 + uSeed ) - 0.5 ) / 255.0 * step( 0.004, a ) ) ;
  gl_FragColor = vec4( max( c, 0.0 ), a );
}

The loop sleeps when there is nothing to do. In this stage nothing moves at all: one frame, then fifteen more samples, and the loop stops.

stage.js — the loop, stage 2
function frame(now) {
  st.raf = 0;
  let rendered = false;
  if (st.needReset) {
    // shadows follow the parts
    renderer.shadowMap.needsUpdate = true;
    st.samples = 0; renderSample(false, 1); st.samples = 1; st.needReset = false; rendered = true;
  } else if (st.samples < st.maxSamples) {
    if (st.samples === 1) renderer.shadowMap.needsUpdate = true;
    renderSample(true, 1 / (st.samples + 1)); st.samples++; rendered = true;
  }
  if (rendered) st.lastRender = now;
  const more = st.samples < st.maxSamples;
  if (more && st.visible) st.raf = requestAnimationFrame(frame);
}

The pixel budget is 3 million pixels. A 1440 × 900 screen at DPR 2 renders at a ratio of about 1.5; the text is HTML over the canvas, so it stays sharp at any ratio.

stage.js — the pixel budget
// pixel budget: ≤ 3.0 MP — the DV49 rule of thumb on the M1 Pro at 120 Hz
const budget = dbg.has('mp') ? +dbg.get('mp') * 1e6 : 3.0e6;
dpr = Math.min(maxDpr, window.devicePixelRatio || 1, Math.sqrt(budget / (W * H)));
renderer.setPixelRatio(dpr);
renderer.setSize(W, H, false);

The camera is a set of keyframes over scroll progress, one list per layout: an orbit (yaw, pitch, distance) round a target, plus a lens shift that moves the watch to the right of the headline without turning the camera. This stage holds the last keyframe, the finished packshot.

stage.js — camera keyframes
const SHOTS = {
  desk: [
    { p: 0.00, yaw: 31, pitch: 10, dist: 285, tgt: [-3, 2, 12], shift: [0.08, 0.17] },
    { p: 0.12, yaw: 31, pitch: 12, dist: 262, tgt: [-2, 0, 9], shift: [0.09, 0.18] },
    { p: 0.34, yaw: 30, pitch: 18, dist: 205, tgt: [-1, -1, 5], shift: [0.1, 0.18] },
    { p: 0.52, yaw: 34, pitch: 14, dist: 240, tgt: [0, 2, 7], shift: [0.09, 0.18] },
    { p: 0.7, yaw: 38, pitch: 9, dist: 275, tgt: [-1, 3, 11], shift: [0.08, 0.17] },
    { p: 0.9, yaw: 30, pitch: 9, dist: 270, tgt: [0, 0, 0], shift: [0.1, 0.2] },
    { p: 1.00, yaw: 28, pitch: 8, dist: 262, tgt: [0, 0, 0], shift: [0.1, 0.2] },
  ],
  phone: [ … ],
};
stage.js — place the camera
function placeCamera(p, jx = 0, jy = 0) {
  const s = shotAt(SHOTS[layout], p);
  // a short screen keeps the watch whole: pull back a little below 760 px tall
  const tall = layout === 'desk' ? Math.min(1, Math.max(0.82, H / 900)) : 1;
  const wide = layout === 'desk' ? Math.min(1, Math.max(0.78, (W / H) / 1.45)) : Math.min(1, Math.max(0.8, (W / H) / 0.46));
  const yaw = s.yaw * DEG, pitch = s.pitch * DEG, d = centre ? s.dist * 0.72 : s.dist / Math.min(tall, wide);
  camera.position.set(s.tgt[0] + Math.sin(yaw) * Math.cos(pitch) * d, s.tgt[1] + Math.sin(pitch) * d, s.tgt[2] + Math.cos(yaw) * Math.cos(pitch) * d);
  camera.up.set(0, 1, 0);
  camera.lookAt(s.tgt[0], s.tgt[1], s.tgt[2]);
  camera.updateProjectionMatrix();
  const e = camera.projectionMatrix.elements;
  const shiftX = layout === 'desk' ? s.shift[0] * Math.min(1, (W / H) / 1.3) : s.shift[0];
  e[8] = -(centre ? -0.02 : shiftX) * 2 + jx * 2 / size.w;
  e[9] = -(centre ? 0.02 : s.shift[1]) * 2 + jy * 2 / size.h;
  camera.projectionMatrixInverse.copy(camera.projectionMatrix).invert();
}

The finishes are shader patches on MeshPhysicalMaterial. Each finish is named after its groove direction, as a watchmaker would name it, and sets the anisotropy tangent from it: turned wheels, soleil, the sunburst dial, Côtes de Genève, perlage. Every value is a uniform, so nothing ever recompiles.

patches.js — groove directions
const GROOVE = {
  tangential: 'vec3( - vWPos.y, vWPos.x, 0.0 )',
  radial: 'vec3( vWPos.x, vWPos.y, 0.0 )',
  sunray: 'vec3( vWPos.x, vWPos.y, 0.0 )',
  cotes: 'vec3( uCotesDir, 0.0 )',
  perlage: 'wPerlageGroove()',
};
patches.js — attach a patch
export function patch(material, mode = 'surface') {
  material.onBeforeCompile = HOOKS[mode];
  material.customProgramCacheKey = () => `dv65-${mode}`;
  material.userData.mode = mode;
  material.userData.u = uniformsFor(mode);
  return material;
}
materials.js — some of the metals
rosePolished: patch(P({ color: C.rose, metalness: 1, roughness: 0.11 }), 'surface'),
roseHand: patch(P({ color: C.rose, metalness: 1, roughness: 0.16 }), 'surface'),
roseSatin: patch(P({ color: C.rose, metalness: 1, roughness: 0.2, anisotropy: 0.6 }), 'tangential'),
roseWheel: patch(P({ color: C.roseDeep, metalness: 1, roughness: 0.2, anisotropy: 0.85 }), 'tangential'),
roseCotes: patch(P({ color: C.rose, metalness: 1, roughness: 0.17, anisotropy: 0.35, envMapIntensity: 0.9 }), 'cotes'),
steelPolished: patch(P({ color: C.steel, metalness: 1, roughness: 0.075 }), 'surface'),
steelBrushed: patch(P({ color: C.steel, metalness: 1, roughness: 0.26, anisotropy: 0.7 }), 'tangential'),
steelSoleil: patch(P({ color: C.steel, metalness: 1, roughness: 0.2, anisotropy: 0.9 }), 'radial'),

The model is in millimetres. Turned parts are lathe profiles: a list of radius and height pairs revolved round the watch axis, with creased normals so polished edges stay crisp.

geom.js — lathe
export function lathe(profile, segments = 128, crease = 0.6, phiStart = 0, phiLength = TAU) {
  const pts = profile.map(([r, z]) => new THREE.Vector2(Math.max(r, 0), z));
  let g = new THREE.LatheGeometry(pts, segments, phiStart, phiLength);
  g.rotateX(Math.PI / 2);                               // lathe height (y) → z
  g = toCreasedNormals(g, crease);
  return clean(g);
}

Every part is a unit: a group with a seat, an exploded offset, a window of scroll progress it will fly in, and its geometry merged by material into as few draws as possible. In this stage the first three units sit in their seats: the case with its four lugs, the domed rose-gold bezel and the fluted crown.

watch.js — a unit
function unit(id, parts, o = {}) {
  const group = new THREE.Group(); group.name = id;
  const spin = new THREE.Group(); group.add(spin);       // running rotation (gears, balance) lives here
  const byMat = new Map();
  const pv = o.pivot;                                   // rotate about the part itself, not the movement centre
  if (pv) { o.pos = (o.pos || V(0, 0, 0)).clone().add(V(pv[0], pv[1], 0)); }
  for (const [g0, mat] of parts) { if (!g0) continue; const g = pv ? g0.clone().translate(-pv[0], -pv[1], 0) : g0; if (!byMat.has(mat)) byMat.set(mat, []); byMat.get(mat).push(g); }
  const mats = [];
  for (const [mat, gs] of byMat) {
    const own = mat;                                      // shared: one material per finish
    const mesh = new THREE.Mesh(merge(gs), own);
    mesh.castShadow = ![M.crystal, M.print, M.blued, M.ruby, M.black, M.dialEdge].includes(mat); mesh.receiveShadow = mat !== M.crystal;
    if (mat === M.crystal) mesh.renderOrder = 10;
    if (mat === M.print) mesh.renderOrder = 2;
    spin.add(mesh); mats.push(own);
  }
  const u = {
    id, group, spin, mats,
    seatPos: o.pos ? o.pos.clone() : V(0, 0, 0), seatQuat: o.quat ? o.quat.clone() : new THREE.Quaternion(),
    off: o.off || V(0, 0, 0), offQuat: o.offQuat || new THREE.Quaternion(), arc: o.arc || V(0, 0, 0), spinFly: o.spinFly || 0,
    win: o.win || null, phase: Math.random() * TAU, breathe: o.breathe ?? 1, label: o.label || id,
  };
  group.position.copy(u.seatPos); group.quaternion.copy(u.seatQuat);
  root.add(group); units.push(u); byId[id] = u;
  return u;
}
watch.js — the case profile
const caseProfile = [[16.0, -5.2], [18.2, -5.2], ...arcPts(17.0, -2.05, 3.25, -1.2, 1.08, 26), [18.35, 1.0], [17.4, 1.0], [17.4, 0.0], [16.0, 0.0], [16.0, -5.2]];
const caseG = lathe(caseProfile, 160, 0.5);
watch.js — the bezel
// bezel (rose gold), domed
const bezelProfile = [[17.0, 1.0], [20.12, 1.0], ...arcPts(18.7, 1.55, 1.48, -0.38, 2.05, 22), [17.45, 2.95], [17.08, 2.62], [17.0, 2.0], [17.0, 1.0]];
unit('bezel', [[lathe(bezelProfile, 160, 0.7), M.rosePolished]], { label: 'Bezel' });
Stage 2 · the 3D studioOpen ↗
The 3D studio: the empty case alone, rendered live in three.js on the velvet: polished steel with four curved lugs, the domed rose-gold bezel and the fluted crown, warm light running round the bezel, the velvet showing through the open middle.

3 · The watch, modelled in code

The movement is laid out like a real one: the barrel, the centre, third and fourth wheels, the escape wheel, the pallet fork and the balance each have a seat on the plate, and a height band so nothing intersects.

watch.js — the layout
export const L = {
  caseR: 20.25, dialR: 16.4,
  barrel: [-3.9, 5.2], centre: [0, 0], third: [4.2, -3.7], fourth: [1.3, -7.2], escape: [-2.7, -8.8],
  balance: [-8.6, -5.6], pallet: [-5.53, -7.26], crownWheel: [2.9, 6.4],
  z: {
    plate: [-4.6, -3.6], barrelTeeth: [-3.5, -3.12], barrelDrum: [-3.12, -2.0],
    centre: [-3.2, -2.86], third: [-2.7, -2.36], fourth: [-2.2, -1.86], escape: [-1.75, -1.57],
    balance: [-2.6, -2.12], spring: -2.02, bridge: [-1.45, -0.65], cock: [-1.2, -0.5], ratchet: [-0.65, -0.4],
    rotor: [-5.1, -4.78], dial: [0, 0.4], hour: 0.82, minute: 1.08, second: 1.36, crystal: [2.0, 2.95],
  },
};

A wheel is a rose-gold rim with ogival teeth and curved spokes, plus a steel pinion, an arbor and a hub. The escape wheel uses the same builder with club teeth. Each wheel is given its exploded offset and its window right here.

watch.js — the gear train
// the gear train: wheel (rose gold) + pinion and arbor (polished steel)
const wheel = (id, [x, y], r, teeth, spokes, zr, pin, pz, off, win, label, mat = M.roseWheel, extra = {}) => {
  const wg = splitGroups(extrude(wheelShape({ r, teeth, spokes, hub: Math.max(0.75, r * 0.2), rim: Math.max(0.42, r * 0.11), twist: 0.42, ...extra }), zr[1] - zr[0], { bevel: 0.025 })).map((g) => g.translate(0, 0, zr[0]));
  const pg = splitGroups(extrude(pinionShape(pin, extra.leaves || 8), pz[1] - pz[0], { bevel: 0.015 })).map((g) => g.translate(0, 0, pz[0]));
  const arbor = cyl(0.22, Z.plate[1], Z.bridge[0] + 0.05, 20);
  const hub = lathe([[0, zr[1]], [0.62, zr[1]], [0.62, zr[1] + 0.12], [0.45, zr[1] + 0.2], [0, zr[1] + 0.2]], 40, 0.6);
  return unit(id, [...wg.map((g) => [g, mat]), ...pg.map((g) => [g, M.steelPolished]), [arbor, M.steelPolished], [hub, M.steelPolished]],
    { pos: V(x, y, 0), off, offQuat: Q(0.45, 0.35, 0), spinFly: 3, win, label });
};
wheel('centre', L.centre, 5.0, 80, 5, Z.centre, 0.75, [Z.plate[1], Z.centre[0]], V(0.5, 0.5, 8), [0.15, 0.28], 'Centre wheel');
wheel('third', L.third, 4.0, 75, 4, Z.third, 0.6, [Z.centre[0], Z.third[0]], V(2, -1, 10), [0.19, 0.31], 'Third wheel');
wheel('fourth', L.fourth, 3.8, 70, 4, Z.fourth, 0.55, [Z.third[0], Z.fourth[0]], V(1.5, -2, 12), [0.22, 0.34], 'Fourth wheel');
wheel('escape', L.escape, 2.45, 15, 5, Z.escape, 0.5, [Z.fourth[0], Z.escape[0]], V(-1.5, -2.5, 13.5), [0.25, 0.37], 'Escape wheel', M.steelPolished, { club: true, add: 0.0, ded: 0.75, toothFill: 0.3, leaves: 7, rim: 0.3 });

The bridges and the rotor are not circles, so they are drawn as signed distance functions (unions of capsules and discs, smooth unions, differences) and turned into shapes by marching squares.

contour.js — SDF primitives
export const sd = {
  circle: (cx, cy, r) => (x, y) => Math.hypot(x - cx, y - cy) - r,
  capsule: (a, b, r) => (x, y) => {
    const vx = b[0] - a[0], vy = b[1] - a[1];
    const t = Math.max(0, Math.min(1, ((x - a[0]) * vx + (y - a[1]) * vy) / (vx * vx + vy * vy)));
    return Math.hypot(x - a[0] - vx * t, y - a[1] - vy * t) - r;
  },
  path: (pts, r) => { const caps = []; for (let i = 0; i < pts.length - 1; i++) caps.push(sd.capsule(pts[i], pts[i + 1], r)); return (x, y) => Math.min(...caps.map((f) => f(x, y))); },
  union: (...fs) => (x, y) => Math.min(...fs.map((f) => f(x, y))),
  smoothUnion: (k, ...fs) => (x, y) => { let d = fs[0](x, y); for (let i = 1; i < fs.length; i++) { const e = fs[i](x, y); const h = Math.max(k - Math.abs(d - e), 0) / k; d = Math.min(d, e) - h * h * k / 4; } return d; },
  sub: (a, b) => (x, y) => Math.max(a(x, y), -b(x, y)),
  intersect: (a, b) => (x, y) => Math.max(a(x, y), b(x, y)),
  ring: (cx, cy, r0, r1) => (x, y) => { const d = Math.hypot(x - cx, y - cy); return Math.max(r0 - d, d - r1); },
};
contour.js — marching squares
export function sdfShape(sdf, [x0, y0, x1, y1], step = 0.06, simplify = 0.035) {
  const nx = Math.ceil((x1 - x0) / step) + 1, ny = Math.ceil((y1 - y0) / step) + 1;
  const v = new Float32Array(nx * ny);
  for (let j = 0; j < ny; j++) for (let i = 0; i < nx; i++) v[j * nx + i] = sdf(x0 + i * step, y0 + j * step);

A bridge is that outline extruded, with Côtes de Genève on its top face and polished anglage on its sides, ruby jewels in polished sinks and blued screws with slots.

watch.js — the train bridge
// train bridge: a sweeping arm over the third, fourth and escape pivots
{
  const f = sd.smoothUnion(0.9,
    sd.path([[13.4, -5.0], L.third, L.fourth, L.escape, [-3.4, -13.6]], 1.55),
    sd.circle(...L.third, 1.55), sd.circle(...L.fourth, 1.55), sd.circle(...L.escape, 1.45),
  );
  const extras = [...jewel(...L.third, zb), ...jewel(...L.fourth, zb), ...jewel(...L.escape, zb), ...jewel(...L.pallet, zb), ...screw(12.2, -5.6, zb, 0.9), ...screw(-3.2, -12.4, zb, 2.0)];
  const f2 = sd.union(f, sd.capsule(L.escape, L.pallet, 0.95), sd.circle(...L.pallet, 1.2));
  bridge('trainBridge', f2, [-7, -15, 15, 0], Z.bridge, extras, V(3, -3, 20.5), [0.38, 0.5], 'Train bridge', Q(0.4, 0.5, -0.2), [3, -7]);
}

The balance has a rim with three arms, eight timing screws and a blued hairspring of thirteen turns.

watch.js — the balance
// balance: rose-gold rim with timing screws, three arms, blued-steel hairspring, roller
{
  const [bx, by] = L.balance;
  const rimS = sdfShape(sd.union(sd.ring(0, 0, 3.85, 4.45), ...[0, 1, 2].map((k) => sd.capsule([0, 0], [Math.cos(k * TAU / 3 + 0.5) * 4.1, Math.sin(k * TAU / 3 + 0.5) * 4.1], 0.2)), sd.circle(0, 0, 0.7)), [-5, -5, 5, 5], 0.05, 0.06);
  const rim = rimS.flatMap((s) => splitGroups(extrude(s, Z.balance[1] - Z.balance[0], { bevel: 0.03 })).map((g) => g.translate(0, 0, Z.balance[0])));
  const screws = [];
  for (let k = 0; k < 8; k++) { const a = k / 8 * TAU + 0.2; const g = clean(new THREE.CylinderGeometry(0.28, 0.28, 0.5, 14).rotateZ(Math.PI / 2).rotateZ(a).translate(Math.cos(a) * 4.62, Math.sin(a) * 4.62, (Z.balance[0] + Z.balance[1]) / 2)); screws.push(g); }
  const spring = hairspring({ r0: 0.7, r1: 3.2, turns: 13, w: 0.055, h: 0.1, n: 900 });
  spring.translate(0, 0, Z.spring);
  const roller = cyl(0.55, Z.escape[0], Z.escape[1], 24);
  const u = unit('balance', [...rim.map((g) => [g, M.roseSatin]), ...screws.map((g) => [g, M.rosePolished]), [clean(spring), M.blued], [roller, M.steelPolished], [cyl(0.2, Z.plate[1], Z.cock[0], 16), M.steelPolished]],
    { pos: V(bx, by, 0), off: V(-5, 0, 16.5), offQuat: Q(0.45, -0.35, 0), spinFly: 2, win: [0.31, 0.43], label: 'Balance' });
  u.kind = 'balance';
}

The dial is a sunburst disc (a custom anisotropy patch with a per-angle groove relief and a fumé edge), a printed layer, faceted applied indices (double at twelve) and a polished flange. The print is a canvas texture drawn in Lato: the only text in the 3D, because it is part of the product, not the page.

watch.js — the dial module
// ---------- dial module: sunburst fumé dial, print, applied indices, polished flange ring ----------
{
  const R = L.dialR, zd = Z.dial;
  const top = clean(new THREE.CircleGeometry(R - 0.05, 256).translate(0, 0, zd[1]));
  const edge = lathe([[0, zd[0]], [R, zd[0]], [R, zd[1] - 0.05], [R - 0.05, zd[1]]], 160, 0.4);
  const print = clean(new THREE.CircleGeometry(R, 96).translate(0, 0, zd[1] + 0.004));
  const idx = [];
  for (let h = 0; h < 12; h++) {
    const a = -h / 12 * TAU;
    const pair = h === 0 ? [-0.48, 0.48] : [0];
    for (const dx of pair) idx.push(batonIndex(h === 0 ? 3.3 : 3.0, h === 0 ? 0.58 : 0.66, 0.46).translate(dx, (h === 0 ? 13.9 : 14.0), zd[1]).rotateZ(a));
  }
  const flange = lathe([[16.15, 0.38], [17.35, 0.38], [17.35, 1.0], [16.98, 1.0], [16.98, 1.72], [16.9, 1.76], [16.15, 0.45]], 160, 0.5);
  const pipe = lathe([[0, zd[1]], [0.95, zd[1]], [0.95, zd[1] + 0.35], [0.6, zd[1] + 0.42], [0, zd[1] + 0.42]], 32, 0.6);
  unit('dial', [[top, M.dial], [edge, M.dialEdge], [print, M.print], ...idx.map((g) => [g, M.rosePolished]), [flange, M.steelPolished], [pipe, M.rosePolished]],
    { off: V(-12, 5, 34), offQuat: Q(0.2, 0.2, 0.05), arc: V(-3, 4, 4), win: [0.56, 0.72], label: 'Dial', breathe: 0.35 });
}
textures.js — the dial print
text('ATELIER H', 6.1, 1.55, 400, 0.16);
text('HAUTE HORLOGERIE', 4.35, 0.52, 700, 0.22);
text('AUTOMATIQUE', -6.4, 0.6, 700, 0.24);
text('CALIBRE AH-01', -7.45, 0.42, 400, 0.22);

The strap is a rounded section swept along a path made by integrating a curvature profile: straight off the lugs, then wrapping back as if round a wrist. Its saffiano grain is a procedural normal map.

watch.js — the strap path
const path = (s) => {
  // integrate a curvature profile: straight off the lugs, then wrapping back round a wrist
  const Ltot = 84, l = s * Ltot;
  let y = 20.3, z = -3.6, a = -0.2; const dl = 0.25;
  for (let q = 0; q < l; q += dl) { const k = q < 3 ? 0 : Math.min(1, (q - 3) / 26) / 52; a -= k * dl; y += Math.cos(a) * dl; z += Math.sin(a) * dl; }
  return { y, z, ty: Math.cos(a), tz: Math.sin(a) };
};

In this stage every unit is in its seat, so the watch is complete. The hands still point at twelve: they get the time from the motor in the next stage.

Stage 3 · the watch, modelled in codeOpen ↗
The whole watch modelled in code, assembled: the rose-gold bezel, the blue fumé sunburst dial with applied indices and its print, the taupe saffiano strap, the hands at twelve.

4 · Scroll is the motor

Nothing on this page assembles by itself. One number, the scroll progress through the tall section from 0 to 1, decides everything, and the page hands it to the stage on every scroll event.

app.js — scroll → progress
function progressFromScroll() {
  const r = build.getBoundingClientRect();
  const span = build.offsetHeight - innerHeight;
  return Math.min(1, Math.max(0, -r.top / Math.max(1, span)));
}
function scrollToP(p, smooth = !reduced) {
  const top = build.offsetTop + p * (build.offsetHeight - innerHeight);
  scrollTo({ top: Math.round(top), behavior: smooth ? 'smooth' : 'auto' });
}
app.js — scroll
addEventListener('scroll', () => { const p = progressFromScroll(); if (stage) stage.setProgress(p); else paint(p); if (reduced) paint(p); }, { passive: true });

Each part flies in its own window of progress, so the parts arrive in assembly order and overlap a little. The strap is on by 0.17; the crystal goes on last, between 0.85 and 0.94.

watch.js — windows
wheel('centre', L.centre, 5.0, 80, 5, Z.centre, 0.75, [Z.plate[1], Z.centre[0]], V(0.5, 0.5, 8), [0.15, 0.28], 'Centre wheel');
wheel('third', L.third, 4.0, 75, 4, Z.third, 0.6, [Z.centre[0], Z.third[0]], V(2, -1, 10), [0.19, 0.31], 'Third wheel');
wheel('fourth', L.fourth, 3.8, 70, 4, Z.fourth, 0.55, [Z.third[0], Z.fourth[0]], V(1.5, -2, 12), [0.22, 0.34], 'Fourth wheel');
wheel('escape', L.escape, 2.45, 15, 5, Z.escape, 0.5, [Z.fourth[0], Z.escape[0]], V(-1.5, -2.5, 13.5), [0.25, 0.37], 'Escape wheel', M.steelPolished, { club: true, add: 0.0, ded: 0.75, toothFill: 0.3, leaves: 7, rim: 0.3 });
watch.js — the ease
/** Ease used for every flight: a fast start that settles softly into the seat. */
const ease = (t) => (t <= 0 ? 0 : t >= 1 ? 1 : 1 - Math.pow(1 - t, 3.2) * (1 - t * 0.0));

update(p) poses every unit for a progress. Inside its window a part goes from its exploded pose to its seat on an eased path, with an arc bump mid-flight and a spin about its own axis; while it waits, it breathes by a third of a millimetre.

watch.js — update
function update(p, now, t, { reduced = false } = {}) {
  state.p = p;
  const running = p >= 0.53;                  // the balance cock is on: the movement beats
  const alive = p >= 0.955;                   // fully assembled: the seconds hand runs
  const ang = handAngles(now, alive);
  let flying = false;
  for (const u of units) {
    let e = 1, raw = 1;
    if (u.win) { raw = (p - u.win[0]) / (u.win[1] - u.win[0]); e = ease(Math.min(1, Math.max(0, raw))); }
    const away = 1 - e;
    if (away > 0) flying = true;
    // position: seat + offset (+ an arc bump mid-flight) + a slow breath while away
    tmpV.copy(u.off).multiplyScalar(away).addScaledVector(u.arc, Math.sin(Math.PI * e) * away);
    if (away > 0 && !reduced && state.breathe) {
      const b = u.breathe * state.breathe * Math.min(1, away * 3);
      tmpV.x += Math.sin(t * 0.55 + u.phase) * 0.35 * b; tmpV.y += Math.sin(t * 0.43 + u.phase * 1.7) * 0.35 * b; tmpV.z += Math.sin(t * 0.37 + u.phase * 2.3) * 0.5 * b;
    }
    u.group.position.copy(u.seatPos).add(tmpV);
    // rotation: exploded tilt → seat, plus a spin about the part's own axis while it flies
    let seat = u.seatQuat;
    if (u === hands.hour) seat = tmpQ2.setFromAxisAngle(zAxis, ang.hour);
    else if (u === hands.minute) seat = tmpQ2.setFromAxisAngle(zAxis, ang.minute);
    else if (u === hands.second) seat = tmpQ2.setFromAxisAngle(zAxis, ang.second);
    tmpQ.copy(seat).multiply(u.offQuat.clone().slerp(new THREE.Quaternion(), e));
    if (u.spinFly) tmpQ.multiply(new THREE.Quaternion().setFromAxisAngle(zAxis, u.spinFly * away));
    if (away > 0 && !reduced && state.breathe) tmpQ.multiply(new THREE.Quaternion().setFromEuler(new THREE.Euler(Math.sin(t * 0.31 + u.phase) * 0.03 * u.breathe, Math.sin(t * 0.27 + u.phase * 1.3) * 0.03 * u.breathe, 0)));
    u.group.quaternion.copy(tmpQ);
    …
  }
  …
}

At the end of its window a part locks with a glint: a brief lift of its specular response. It is computed from scroll, not from time, so it plays again when you scrub back and forth. Each unit owns a clone of its materials for this, so only the part that locks flashes.

watch.js — the glint
// lock glint: a short flash as the part seats (scroll-driven, so it replays when you scrub)
const g = u.win && state.glints ? Math.max(0, 1 - Math.abs(raw - 1) / 0.07) ** 2 * (raw <= 1.07 ? 1 : 0) : 0;
for (const m of u.mats) if (m.userData.u) m.userData.u.uGlint.value = g;
patches.js — the glint in the shader
// lock glint: a brief lift of the specular response, driven by scroll (uGlint 0..1)
  s = rep(s, '#include <opaque_fragment>', `outgoingLight *= 1.0 + uGlint * 0.9;
#include <opaque_fragment>`, 'fragment');

Once the balance cock is on (progress 0.53) the movement beats: the balance swings ±270° at 4 Hz, the escape wheel steps, the pallet fork flicks and the train turns at its real ratios.

watch.js — the movement
// the movement running: balance swings ±270° at 4 Hz, the escapement steps, the train turns
const tb = running ? t : 0;
const bal = byId.balance, esc = byId.escape, pal = byId.pallet;
const amp = running && !reduced ? 4.7 : 0;
bal.spin.rotation.z = amp * Math.sin(tb * TAU * 4);
const beats = running ? Math.floor(tb * 8) + smooth(Math.min(1, ((tb * 8) % 1) / 0.25)) : 0;
esc.spin.rotation.z = -beats * (TAU / 30);
pal.spin.rotation.z = running ? 0.12 * (Math.floor(tb * 8) % 2 ? 1 : -1) * (1 - Math.exp(-((tb * 8) % 1) * 9)) : 0;
byId.fourth.spin.rotation.z = beats * (TAU / 30) / 16;
byId.third.spin.rotation.z = -beats * (TAU / 30) / 16 / 7.5;
byId.centre.spin.rotation.z = beats * (TAU / 30) / 16 / 60;

The hands always show your local time. When the watch is complete, the seconds hand runs at eight beats a second.

watch.js — the time
function handAngles(now, alive) {
  const h = now.getHours() % 12, m = now.getMinutes(), s = now.getSeconds(), ms = now.getMilliseconds();
  const sec = alive ? s + Math.floor(ms / 125) / 8 : 0;
  return { hour: -((h + m / 60 + s / 3600) / 12) * TAU, minute: -((m + s / 60) / 60) * TAU, second: -(sec / 60) * TAU, sec };
}

Scrolling only sets a target. Each animation frame eases the shown progress toward it, poses the watch and renders. A moving frame renders once; when everything rests, the accumulation from stage 2 refines it. The loop keeps running only while something moves: a scroll in flight, the beating movement, the ticking watch, or breathing parts.

stage.js — the loop, stage 4
function frame(now) {
  st.raf = 0;
  const dt = Math.min(0.1, (now - lastT) / 1000); lastT = now;
  const t = (now - clock0) / 1000;
  // smoothing toward the scroll target (critically damped feel)
  const k = reduced ? 1 : 1 - Math.exp(-dt * 7.5);
  const dp = st.target - st.shown;
  st.shown = Math.abs(dp) < 1e-4 ? st.target : st.shown + dp * k;

  const flags = watch.update(st.shown, new Date(), t, { reduced });
  const scrolling = Math.abs(dp) >= 1e-4;
  const tick = flags.alive && flags.sec !== st.lastSec; st.lastSec = flags.sec;
  const poseMoving = scrolling || flags.running;              // parts move fast enough to need a fresh frame
  const breathing = st.breathe && flags.flying;               // slow drift of the exploded parts (sub-pixel per frame)
  let rendered = false;
  if (st.needReset || poseMoving || tick) {
    // shadows follow the parts
    renderer.shadowMap.needsUpdate = true;
    st.samples = 0; renderSample(false, 1); st.samples = 1; st.needReset = false; rendered = true;
  } else if (st.samples < st.maxSamples) {
    if (breathing || st.samples === 1) renderer.shadowMap.needsUpdate = true;
    renderSample(true, 1 / (st.samples + 1)); st.samples++; rendered = true;
  } else if (breathing && now - st.lastRender > 90) {
    renderer.shadowMap.needsUpdate = true;
    renderSample(true, 1 / st.maxSamples); rendered = true;
  }
  if (rendered) st.lastRender = now;
  if (st.onFrame) st.onFrame(st.shown, flags);
  const more = scrolling || flags.running || flags.alive || breathing || st.samples < st.maxSamples;
  if (more && st.visible) st.raf = requestAnimationFrame(frame);
}

The page side follows the shown progress: three named stages, a label for the canvas, a polite live region that says which step you are on, and on a phone a three-segment rail with the step's name. The thin scroll cue glides you to the movement and fades once you scroll.

app.js — the three stages
const STAGES = [
  { from: 0, to: 0.2, label: 'Exploded view', say: 'Step 1 of 3: exploded view. Scroll to assemble.', aria: 'An ATELIER H. watch in exploded view: dial, hands, rotor and movement parts floating apart from the case.' },
  { from: 0.2, to: 0.8, label: 'Gears & case locking', say: 'Step 2 of 3: the movement is being assembled and the case is closing.', aria: 'The ATELIER H. movement being assembled inside the case: gold gear wheels, bridges with rubies, the balance.' },
  { from: 0.8, to: 1.0001, label: 'Ticking assembly', say: 'Step 3 of 3: fully assembled. The watch is running on your local time.', aria: 'The finished ATELIER H. Calibre AH-01: rose-gold bezel, blue fumé sunburst dial, taupe leather strap, hands showing the current time.' },
];
app.js — paint
function paint(p) {
  P = p;
  const i = stageOf(p);
  if (i !== stageIdx) {
    stageIdx = i;
    railLabel.textContent = `0${i + 1} · ${STAGES[i].label}`;
    canvas.setAttribute('aria-label', STAGES[i].aria);
    if (booted) live.textContent = STAGES[i].say;
  }
  STAGES.forEach((s, k) => {
    const f = Math.min(1, Math.max(0, (p - s.from) / (Math.min(1, s.to) - s.from)));
    if (rail[k]) rail[k].style.setProperty('--f', f.toFixed(3));
  });
  cue.classList.toggle('gone', p > 0.025);
}
watch.css — the phone rail
.rail { position: absolute; left: 16px; right: 16px; bottom: calc(2.4vh + 6px); display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; }
Stage 4 · scroll is the motorOpen ↗
Halfway through the scroll: the strap is on, the gear train and the bridges with ruby jewels sit in the open case, the blue dial still floats in front of it, the hands hang above, and the rose-gold rotor with its A drifts in from the right.
Stage 4 · on a phoneOpen ↗
The same moment on a phone: the movement assembling, the headline centred low, and the three-segment rail reading 02 · Gears & case locking.

5 · Tiles, Find a part, the bag

The three state tiles from the board are real buttons. Each shows a render of the live stage in that state, a thin meter of its share of the progress, and the board's captions; clicking one (or Enter) scrolls to that state, and the current one carries aria-current="step".

index.html — the tiles
<ol class="steps" id="steps" aria-label="Assembly steps">
  <li><button type="button" class="step" data-p="0" aria-current="step">
    <span class="shot"><img src="assets/renders/tile_start.webp" alt="" width="920" height="400" decoding="async"></span>
    <span class="meter" aria-hidden="true"><i></i></span>
    <span class="cap"><b>Start: exploded view</b><span>Initial state, components scattered</span></span>
  </button></li>
  …
</ol>
watch.css — tiles
.steps { position: absolute; left: 14px; right: 14px; bottom: max(3.2vh, 18px); margin: 0; padding: 0; list-style: none; display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.step { display: grid; gap: 0; width: 100%; padding: 0; background: none; border: 0; text-align: left; cursor: pointer; color: inherit; border-radius: 10px; }
.shot { display: block; height: var(--tiles-h); border-radius: 8px; overflow: hidden; border: 1px solid #232323; background: #070605; position: relative; transition: border-color .3s; }
.shot img { width: 100%; height: 100%; object-fit: cover; object-position: 50% 45%; display: block; transition: transform .6s cubic-bezier(.2, .7, .2, 1), filter .3s; filter: brightness(.86); }
.step:hover .shot img { transform: scale(1.03); filter: brightness(1); }
.step[aria-current="step"] .shot { border-color: rgba(226, 195, 162, .55); }
.step[aria-current="step"] .shot img { filter: brightness(1.02); }
.meter { display: block; height: 2px; margin: 8px 2px 0; background: rgba(255, 255, 255, .1); border-radius: 1px; overflow: hidden; }
.meter i { display: block; height: 100%; width: 100%; background: linear-gradient(90deg, var(--rose-2), var(--rose)); transform-origin: 0 50%; transform: scaleX(0); }

When the watch is complete, a quiet product line appears under the headline with the price and Add to bag. It only takes focus once it is shown.

index.html — the product line
<div class="final" id="final" aria-hidden="true">
  <p class="final-line">Calibre AH-01 <span aria-hidden="true">·</span> 40 mm <span aria-hidden="true">·</span> rose gold &amp; steel</p>
  <p class="final-price">€ 18,400</p>
  <button class="cta" id="add-btn" type="button" tabindex="-1">Add to bag</button>
</div>
app.js — shown when complete
const done = p >= 0.955;
if (done !== alive) {
  alive = done;
  finalBox.classList.toggle('on', done);
  finalBox.setAttribute('aria-hidden', String(!done));
  addBtn.tabIndex = done ? 0 : -1;
}

Find a part is a combobox. Its list comes from the model itself (every unit with a window, in fitting order), the filter takes substrings first and then looser in-order matches, and picking a part scrolls to the moment it is fitted.

app.js — the parts list
function partList() {
  if (parts.length) return parts;
  const fixed = [{ label: 'Case', p: 0, note: 'in place' }, { label: 'Bezel', p: 0, note: 'in place' }, { label: 'Crown', p: 0, note: 'in place' }];
  const seen = new Set();
  const moving = stage ? stage.watch.units.filter((u) => u.win).map((u) => ({ label: u.label, p: Math.min(1, u.win[1] + 0.012) })) : [
    ['Strap', 0.17], ['Main plate', 0.17], ['Barrel', 0.24], ['Centre wheel', 0.28], ['Third wheel', 0.31], ['Fourth wheel', 0.34], ['Escape wheel', 0.37], ['Pallet fork', 0.4],
    ['Balance', 0.43], ['Barrel bridge', 0.47], ['Train bridge', 0.5], ['Balance cock', 0.52], ['Rotor', 0.58], ['Stem', 0.58], ['Caseback', 0.62], ['Dial', 0.72],
    ['Hour hand', 0.8], ['Minute hand', 0.84], ['Seconds hand', 0.88], ['Sapphire crystal', 0.94]].map(([label, p]) => ({ label, p }));
  parts = [...fixed, ...moving.sort((a, b) => a.p - b.p)].filter((x) => (seen.has(x.label) ? false : seen.add(x.label)));
  return parts;
}
app.js — the filter
// fuzzy-ish: every typed character in order
const match = (s) => { let i = 0; for (const c of s.toLowerCase()) if (c === n[i]) i++; return i === n.length; };
// substring matches first (in assembly order), then looser in-order matches
const all = partList();
shown = !n ? all : [...all.filter((x) => x.label.toLowerCase().includes(n)), ...all.filter((x) => !x.label.toLowerCase().includes(n) && match(x.label))];

The search and the bag are sheets: non-modal dialogs with a focus trap. showModal() makes the whole page inert, which cost frames here; these open with focus inside, Escape closes them, and focus returns to the button that opened them.

app.js — sheets
function openSheet(id, from) {
  const el = $('#' + id); opener = from || document.activeElement;
  el.hidden = false;
  if (id === 'search') { $('#q').value = ''; renderParts(''); $('#q').focus(); }
  if (id === 'bag') { renderBag(); (el.querySelector('.cta, .ghost, [data-close]')).focus(); }
}
function closeSheet(el) { el.hidden = true; if (opener && opener.focus) opener.focus(); }
$$('.sheet').forEach((el) => {
  el.addEventListener('click', (e) => { if (e.target === el || e.target.closest('[data-close]')) closeSheet(el); });
  el.addEventListener('keydown', (e) => {
    if (e.key === 'Escape') { e.preventDefault(); closeSheet(el); return; }
    if (e.key !== 'Tab') return;
    const f = $$('button:not([disabled]), input, [href], [tabindex="0"]', el).filter((x) => x.offsetParent !== null);
    if (!f.length) return;
    const first = f[0], last = f[f.length - 1];
    if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
    else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
  });
});

The bag is kept under a versioned key, and any older key of this component is wiped on load, so a changed format never reads stale data.

app.js — versioned storage
// storage: versioned keys only; wipe anything older from this component
const store = {
  get(k, d) { try { const v = localStorage.getItem(k); return v === null ? d : JSON.parse(v); } catch { return d; } },
  set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* private mode */ } },
};
try { for (let i = localStorage.length - 1; i >= 0; i--) { const k = localStorage.key(i); if (k && k.startsWith('dv65') && !k.startsWith('dv65a.')) localStorage.removeItem(k); } } catch { /* ignore */ }

With a mouse, the camera leans up to 2.6° toward the pointer, damped, so the light slides over the metal as you move. It is off for touch and for reduced motion.

app.js — pointer parallax
// pointer parallax (mouse only): the light slides over the metal as you move
if (!reduced) build.addEventListener('pointermove', (e) => { if (e.pointerType !== 'mouse') return; stage.setPointer((e.clientX / innerWidth) * 2 - 1, (e.clientY / innerHeight) * 2 - 1); }, { passive: true });
build.addEventListener('pointerleave', () => stage.setPointer(0, 0));
stage.js — the lean
const yaw = (s.yaw + rig.px * 2.6) * DEG, pitch = (s.pitch - rig.py * 1.8) * DEG, d = centre ? s.dist * 0.72 : s.dist / Math.min(tall, wide);
Stage 5 · the additionsOpen ↗
The finished watch ticking at the end of the scroll: the product line Calibre AH-01, 40 mm, rose gold and steel, the price and Add to bag under the headline, a badge of 1 on the bag, and the three state tiles with FINAL current.
Stage 5 · the finished watch on a phoneOpen ↗
The finished watch on a phone, frontal, the strap running off the top and bottom, the product line and Add to bag above the centred headline.

6 · Fallbacks and the frame budget

Without WebGL 2 (or with ?nogl) the canvas is hidden and a still of the stage stands in, swapped per state as you scroll, so the tiles, the product line and the bag still work.

app.js — no WebGL
if (new URLSearchParams(location.search).has('nogl') || !hasGL2()) {
  noGL = true; document.documentElement.classList.add('no-gl'); canvas.hidden = true; $('#fallback').hidden = false;
  paint(progressFromScroll()); booted = true; resolveReady(); return;
}
app.js — a still per state
function setFallback(i) {
  const img = $('#fallback');
  const phone = innerWidth / innerHeight < 0.82;
  img.src = `assets/renders/${phone ? 'phone' : 'hero'}_${['start', 'progress', 'final'][i]}.webp`;
  img.alt = STAGES[i].aria;
}

Without JavaScript the headline still reads and a note says what the page would do.

index.html — without JavaScript
<noscript><p class="nojs">The assembly needs JavaScript. The finished watch: ATELIER H. Calibre AH-01, 40 mm, rose gold and steel, blue fumé sunburst dial.</p></noscript>

Holding the frame rate at 120 Hz while about half a million vertices move took a few measured steps. While anything moves, the scene renders into a smaller window of the same target (no reallocation) and the history pass is skipped; when everything rests it renders at full size and converges again.

stage.js — dynamic resolution
// dynamic resolution: while things move the scene renders into a smaller window of the same target (no reallocation);
// when everything is still it renders at full size and the jittered samples converge to a supersampled frame
function renderSample(jitter, w, scale = 1) {
  const k = jitter ? st.samples : 0;
  const vw = Math.max(1, Math.round(size.w * scale)), vh = Math.max(1, Math.round(size.h * scale));
  const jx = jitter ? halton(k + 1, 2) - 0.5 : 0, jy = jitter ? halton(k + 1, 3) - 0.5 : 0;
  placeCamera(st.shown, jx * size.w / vw, jy * size.h / vh);
  rtScene.viewport.set(0, 0, vw, vh); rtScene.scissor.set(0, 0, vw, vh); rtScene.scissorTest = scale < 1;
  renderer.setRenderTarget(rtScene); renderer.clear(); renderer.render(scene, camera);
  if (stopAt <= 1) return;
  // history: a moving frame skips it (it would be thrown away next frame); a still frame blends into it
  let src, sx = 1, sy = 1;
  if (w >= 1 && scale < 1) { src = rtScene.texture; sx = vw / size.w; sy = vh / size.h; st.histValid = false; }
  …
}

Shadows refresh every other frame while scrolling, and a ticking watch takes five samples per beat instead of sixteen.

stage.js — the loop, finished
if (!st.histValid && !poseMoving && !pMoving) st.needReset = true;   // came to rest after scaled frames: start clean at full size
if (st.needReset || poseMoving || pMoving || tick) {
  // shadows follow the parts; while scrolling they refresh every other frame (sub-pixel lag, half the cost)
  if (st.needReset || tick || ((scrolling || flags.running) && (st.shadowTick = (st.shadowTick + 1) % 2) === 0)) renderer.shadowMap.needsUpdate = true;
  st.samples = 0; renderSample(false, 1, poseMoving || pMoving ? st.motionScale : 1); st.samples = 1; st.needReset = false; rendered = true;
} else if (st.samples < (flags.alive ? 5 : st.maxSamples)) {   // ticking: a few samples per beat, not sixteen
  …

Parts that end up enclosed stop drawing: the dial covers the movement, the caseback covers the rotor, the case swallows the stem. That is about two thirds of the triangles of the finished watch.

watch.js — enclosed parts
// parts that end up fully enclosed stop drawing: the dial covers the movement once it is seated, the caseback covers
// the rotor, the case swallows the stem (about two thirds of the triangles; the finished watch draws only what shows)
const covered = p >= 0.725, backed = p >= 0.625;
for (const u of units) {
  const hide = (u.inside === 'dial' && covered) || (u.inside === 'back' && backed) || (u.inside === 'case' && p >= 0.59);
  u.group.visible = !hide;
}

If a third of recent animated frames run long, the pixel ratio steps down (twice at most), and only once the page is at rest, because a resize reallocates the render targets. The loop also sleeps when the section is off-screen or the tab is hidden.

app.js — adaptive quality
function adaptQuality() {
  let last = 0, long = 0, n = 0, scale = 1, pending = 0, steps = 0;
  const watch = (t) => {
    if (last && stage.st.raf) { n++; if (t - last > 1000 / 50) long++; }
    last = t;
    if (n >= 120) { if (long > n / 3 && steps + (pending ? 1 : 0) < 2) pending = Math.max(0.7, scale - 0.15); n = long = 0; }
    if (pending && performance.now() - lastScroll > 600 && Math.abs(stage.st.target - stage.st.shown) < 1e-4) { scale = pending; pending = 0; steps++; stage.setQuality(scale); }
    requestAnimationFrame(watch);
}
app.js — sleep off-screen
new IntersectionObserver(([e]) => stage.setVisible(e.isIntersecting), { rootMargin: '50px' }).observe(build);
document.addEventListener('visibilitychange', () => stage.setVisible(!document.hidden));

With reduced motion there is no breathing, no light drift, no parallax and no scroll smoothing, and the movement does not swing; scroll still assembles the watch, because that is the content.

watch.css — reduced motion
@media (prefers-reduced-motion: reduce) {
  .velvet .spot { animation: none; }
  .final, .toast, .shot img, .cue { transition: none; }
}
Stage 6 · the finished pageOpen ↗
The first screen of the finished page: the watch in exploded view, the dial module floating front left, the hands and the stem up left, the rotor bottom right, the movement lifted out of the case, a thin scroll cue under the headline, and the three state tiles below.
Stage 6 · on a phoneOpen ↗
The first screen on a phone: the exploded watch stacked vertically, the headline centred low, a chevron, and the rail reading 01 · Exploded view.

Make it yours

  • Your object is js/watch.js: each part is a unit() with geometry, a seat, an exploded off and offQuat, an arc and a window win. Add a part, give it a window, and it flies, locks and glints like the others.
  • Your finishes are in js/materials.js (colours are linear) and the patch modes in js/patches.js; the studio light is the panel list in js/env.js.
  • Your pacing is the height of .build, the windows in watch.js, the camera keyframes in SHOTS and the three STAGES in app.js.
  • Scripts can drive it through window.__dv65: ready, p, goto(p) and stage.
  • Accessibility is already there: a polite live region per step, a canvas label that follows the state, real buttons for the tiles, a combobox with arrow keys, dialogs with a focus trap, visible focus, 44 px targets and prefers-reduced-motion honoured.

The code is under the MIT licence: use it in your own projects, commercial ones included. three.js r186 is MIT too. The fonts are Lato and Inter (SIL Open Font License, from Google Fonts). ATELIER H. and the Calibre AH-01 are made up. No AI picture is used: every part, every finish, the studio light and the velvet are made in code, and the tile and fallback stills are renders of the same stage.

The one idea worth keeping

Give every part a seat, an exploded pose and a window, and let one scroll progress decide where each one is. The flights, the locks, the glints and the beating movement all follow from that number, so the page can never be somewhere the reader did not scroll it.