A launch page for the fictional watchmaker Ostra and its dive watch, the Tidal 41, in the pattern of a product page: pre-rendered frames of the watch play on a 2D canvas as you scroll, with callouts that follow each part. Ostra is invented for this demo and every figure is illustrative.
How it's built
- The watch is code.
scripts/ostra-render/ holds a procedural Three.js model in millimetres: lathe-turned case with polished chamfers, tapered lugs, a toothed bezel with a ceramic-look insert, a domed crystal, a sunburst dial with applied indices and lume, three hands, a knurled crown between guards, a caseback with a window, and behind it a movement with perlage, bridges with Cotes de Geneve stripes, jewels, blued screws, a balance wheel with a hairspring and a gold-toned rotor. Three straps are built the same way: a steel bracelet of brushed and polished links, ribbed rubber with a pin buckle and woven canvas with stitching. - Frames are rendered at build time, not in the browser. A Playwright script drives headless Chromium on a local page, renders every frame with physical materials, a 1024 px soft-box studio environment, 8192 px soft shadows and a tight bloom at twice the final size (4800 px, MSAA on top), halves it in the page (supersampling) and hands a lossless PNG to Node, where sharp resizes it for the phone set, adds a little luma noise (no banding in the dark gradients) and encodes WebP. Dial and bezel textures are 4096 px so the dial close-up stays sharp. Run it with
node scripts/ostra-render/render.mjs all (about 15 minutes; sample turn:0,night:23 renders a few frames into tmp/ostra-qa/ to judge quality and size). The watch never touches a page bundle: no WebGL and no Three.js at runtime, so it runs on any phone. - 330 frames in seven sequences.
turn (120: a full spin, then onto its side), explode (90), macro (48: a dolly onto the dial), night (24: lights out) and three strap turntables of 16. They are written in two sets: 2400 px for desktops and tablets and 1200 px for phones and Save-Data, quality about 0.80 and 0.70. The macro and night frames fill the whole stage and are drawn above 1x, so they are 3000 px (desktop set) and 1600 px (phone set), and the night frames are encoded at quality 0.90 and 0.82 because smooth dark gradients turn blocky otherwise. Sizes in total are in the status log. anchors.json is 8 KB gzip. - Callouts follow the parts. The render script projects named 3D points (crown, bezel, rotor, lume and more) into screen space for every frame and stores them in
anchors.json, with a facing factor. The page interpolates between frames, so a marker stays on its part while the watch turns, and fades when the part turns away. Labels sit in side columns with a leader line drawn in on arrival; on phones the same callouts become numbered markers and a legend. - Hand-off to the strap. The strap copy fades in when its chapter top reaches 64% of the viewport. The chapter has
lead: 64 (vh), so the stage already shows the studio-lit strap turntable from that moment (a 0.3 s crossfade from the dark night frame) and the strap text never sits on a night frame; the night chapter is 64 vh longer so it still plays its full 130 vh. - Scroll to frame. One ScrollTrigger maps progress to a chapter, a sequence and a frame (
sequence.ts, unit-tested). The canvas redraws only when the frame, the size or the strap changes, and costs nothing while idle. Frames are drawn with a square layout that either fits the stage or covers it (the dial close-up), and its edges fade into the page colour. - Loading. The shared loader leaves once the hero frame and every 8th frame of the turn are fetched, the fonts are in and the first frame is drawn. The rest streams in by refinement tier (every 8th, 4th, 2nd, then the odd frames) across all sequences, so any scroll position has a frame nearby; the canvas always draws the nearest decoded frame. Compressed frames are all kept, but decoded bitmaps live in a cache around the playhead bounded in bytes (640 MB for the 2400 set, 260 MB for the 1200 set; the whole decode window of 11 frames always fits, even at 3000 px), so memory stays bounded. The canvas backing store goes up to 3200 px, so a 1567 px stage at 2x DPR is drawn from the 3000 px frames almost 1:1. Phones and small windows (square under about 800 device px) and Save-Data get the 1200 set; Save-Data also skips the odd frames.
- Page colour. The render script measures the studio background of the frames and stores it in
anchors.json, so the page background matches the canvas edges; it darkens with the night chapter. - Strap picker. Three radio buttons (arrow keys move between them) swap the turntable; drag the watch or use the slider to turn it. The fictional pre-order form validates an email and sends nothing.
Reduced motion
No scrubbing and no smooth-scroll effects: each chapter shows one still (hero, a three-quarter turn, profile, the exploded view, the dial, night, strap) with its callouts, crossfaded in 0.15 s. Only those stills and the pickable straps are downloaded.