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.
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.
<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">
<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.
: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.
.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.
.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.
@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; }
…
}

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.
<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.
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.
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.
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.
// 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.
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.
// 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.
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.
// 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.
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: [ … ],
};
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.
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()',
};
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;
}
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.
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.
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;
}
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);
// 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' });

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.
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.
// 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.
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); },
};
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.
// 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.
// 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.
// ---------- 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 });
}
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.
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.

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.
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' });
}
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.
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 });
/** 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.
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.
// 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;
// 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.
// 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.
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.
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.
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.' },
];
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);
}
.rail { position: absolute; left: 16px; right: 16px; bottom: calc(2.4vh + 6px); display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; }


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".
<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>
.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.
<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 & steel</p>
<p class="final-price">€ 18,400</p>
<button class="cta" id="add-btn" type="button" tabindex="-1">Add to bag</button>
</div>
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.
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;
}
// 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.
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.
// 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.
// 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));
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);


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.
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;
}
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.
<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.
// 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.
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.
// 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.
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);
}
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.
@media (prefers-reduced-motion: reduce) {
.velvet .spot { animation: none; }
.final, .toast, .shot img, .cue { transition: none; }
}


Make it yours
- Your object is
js/watch.js: each part is aunit()with geometry, a seat, an explodedoffandoffQuat, anarcand a windowwin. 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 injs/patches.js; the studio light is the panel list injs/env.js. - Your pacing is the height of
.build, the windows inwatch.js, the camera keyframes inSHOTSand the threeSTAGESinapp.js. - Scripts can drive it through
window.__dv65:ready,p,goto(p)andstage. - 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-motionhonoured.
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.
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.