Projects

Keycraft

A custom keyboard assembled as you scroll: the milled aluminum case first, then all sixty-eight keys seat into place.

A scroll-driven WebGL microsite for Keycraft, a fictional custom-keyboard maker. Scroll, and a 3D keyboard assembles itself on a build bench: the milled 6063 aluminum case first, then all sixty-eight keys seat into place: fifty-seven single-unit keys first, then the eleven long keys with their brass wire stabilizers, while the camera eases between five angles, one per stage of the build.

Built as a narrative-scrolling experiment: the entire product story told in one gesture, no clicks required. Scroll is the only input: page progress is the clock that drives the 3D assembly, the camera, and the fixed build rail that tracks them all. Solo project, open source, deployed live at keycraft.anjana784.dev.

The challenge

Make an assembly sequence read as choreography, not as sixty-eight keys sliding into their slots. The story has four acts (the case, the switches, the long keys, the finished board), and each has to feel like a stage of a real build: the case sits on display first, then the keys materialize above their slots and cascade down one after another, not in a snap.

Two problems dominate. The first is the camera: scroll isn't scrubbing a timeline, it's a continuous signal, so the camera can't just cut between stage angles; it has to ease into each one and hold it without ever feeling frozen, then give the finished board a slow orbit so the finale rewards the scroll. The second is the keys themselves: a uniform drop would read as an animation, but an organic cascade needs each key to have its own staging spot above the board, its own arrival time, and its own hover motion while it waits, all deterministic, so the scatter is identical on every visit.

And the whole thing runs in a fixed background canvas behind the scrolling page, animating 69 meshes per frame with zero React re-renders.

The approach

One scroll clock, three consumers. getScrollProgress() normalizes page scroll to 0..1, and scrollRange(from, to) smoothstep-eases a sub-window of it. The same windows drive everything: case in the hero (0–0.4), small keys seating (0.4–0.6), long keys seating (0.6–0.8), and finale orbit (0.85–1). So the DOM build rail, the stage copy, and the 3D scene always agree, because they all read the same number.

Each key is driven by its own window: it stays hidden until the scroll enters its stage, then materializes in a staging position sampled from a simplex-noise field, so every key hovers in its own organic spot above the board, tilted slightly and drifting on a slow time-based noise, before smoothstep-easing down into its slot. A per-key stagger spreads the arrivals across the stage window, so the seating reads as a cascade rather than a snap.

The camera is a list of five poses keyed to scroll sections. Each transition eases over a short scroll span, held angles get a gentle sine/cosine drift so they never feel frozen, and after 0.85 an orbit is blended in over the finished board. The camera chases its target with exponential damping, which is what makes the motion read as cinematic rather than stepped.

Technical details

The shared scroll clock

scroll.ts is two functions: getScrollProgress() (scrollY over total scrollable range) and scrollRange(progress, from, to), which remaps a sub-window into a 0..1 with a t * t * (3 - 2t) smoothstep. Every consumer (the useFrame loops in keyboard.tsx and camera-rig.tsx, and the rAF-throttled useBuildProgress hook that drives the DOM rail) calls getScrollProgress() directly each frame/scroll event. No shared state, no subscriptions; the page position is simply the clock.

Per-key assembly

The GLB ships one mesh per key (68 key nodes plus the case, three materials total). Each frame, Keyboard computes per key: raw = clamp01((progress - (from - POP_LEAD)) / (to - from)), so keys materialize slightly before their stage starts, then applies the per-key stagger and smoothstep-eases the result. Visibility, scale (0.2 → 1), position, and tilt are all derived from that single eased value, so the seat-out reads as one motion. Staging positions come from createNoise3D seeded by key index and home position, producing deterministic scatter with no runtime randomness, and a time-based noise term (t * 0.4 + i) makes airborne keys hover gently while they wait. Keys tilt up to ±0.3 rad while airborne and land perfectly level, and the whole board scales down on narrow viewports (min(1, aspect * 1.25)) so the top-down camera always fits it in frame.

Camera choreography

Five poses keyed to scroll positions (hero steep top-down → case offset left → small keys offset right → long keys offset left-front → finale pulled back high). The active section is the last one whose start has been passed; each transition eases in over a TRANSITION span of 0.08 of the scroll range. DRIFT of 0.04 applied as sine/cosine of elapsed time keeps held angles alive, and after 0.85 a slow orbit (scrollRange(progress, 0.85, 1) * 0.45) swings the camera around the finished board. The camera chases the computed target with 1 - exp(-2.5 * delta), so every move is damped and continuous. No camera ever snaps to a new angle.

Build rail in sync

The fixed rail on the left (desktop) and the compact stage tag (mobile) use the same STAGE_WINDOWS constants as the 3D (Case, Switches, Long keys, Finished), with a brass progress line whose height is progress * 100% and a marker that sits on the active stage. The scroll handler is rAF-throttled so the DOM updates at frame rate without layout thrash.

Readability over the 3D

White text has to stay readable over white keys, so a bottom scrim (h-[22vh]h-[28vh], gradient from the ink background) sits between the canvas and the content, and on small screens each stage gets a translucent glass panel with a backdrop blur so copy stays legible without the scrim reaching high enough to cover the board.

Outcome

Shipped live at keycraft.anjana784.dev in August 2026, open source, with the assembly windows and camera poses documented in the README.

The site tells a four-act build story in one scroll: the milled case under studio HDRI lighting, fifty-seven switches cascading into their slots, the eleven long keys landing with their brass stabilizers, and a slow orbital reveal of the finished board. Scroll back up and the build runs in reverse. It works identically on desktop and mobile: the rail collapses to a compact stage tag, the board rescales to fit the frame, and the glass panels keep the copy readable over the keys.

The deliverable is the architecture itself: one normalized scroll value driving DOM and WebGL alike, and per-key animation math cheap enough to run for all 68 keys inside a single useFrame, with no React re-renders anywhere in the loop.

Reflection

The single scroll clock is the whole architecture. Everything else (the camera poses, the assembly windows, the build rail) is a function of one number, which made the DOM and the 3D trivially impossible to desync. I'd reach for the same shape for any scroll-driven scene.

The deterministic scatter was the subtle win: the keys look loosely organized, like a pile of parts on a bench, but every position is a pure function of the key's home slot: reproducible on every visit, no randomness to manage, and the same noise field doubles as the per-key stagger. Simplex noise for organization is a pattern I'll reuse.

Per-mesh refs cap out at a few hundred keys before the per-frame loop gets heavy, but for a single 68-key board it's a non-issue. If I scaled this to multiple boards or a configurator, I'd move to an InstancedMesh with a packed transform buffer, which pushes the same math to the GPU. And the finale CTAs are placeholder links. Wiring "Configure yours" to a real configurator (the watch configurator's material-swap architecture would slot right in) is the natural sequel.