Aperture — a scroll-scrubbed mechanism that ships no 3D assets
Self-initiated concept. Aperture is a fictional product; no affiliation with any manufacturer. Design and build: Canon Chong · ~half a day · React Three Fiber, three.js, custom design system
The problem
Scroll-driven product pages nearly always resolve into one of two things.
The first is a video scrub: a pre-rendered image sequence stepped by scroll position. It looks expensive because it was, it ships several megabytes, it cannot respond to anything except the one axis it was baked against, and on a slow connection the hero is a blank rectangle for the first few seconds.
The second is keyframed states that cross-fade. Three positions, some easing between them. It reads as a slideshow the moment anyone scrolls back up, because nothing is genuinely reversible — you are watching a transition play, not a mechanism move.
Both share a third failure: they treat prefers-reduced-motion as a switch that turns the whole thing off. Someone who asked for less movement gets a static image and none of the content the motion was carrying.
Constraints I set
- No 3D assets. No GLTF, no textures, nothing to download and nothing to wait for.
- 60fps through a full-page scrub, on a real measurement, not a vibe.
- Genuinely reversible. Every visual state is a pure function of scroll position, so scrolling up
is not a special case that needs its own code.
- Reduced motion degrades the movement, not the page.
- WebGL is a capability, not a guarantee.
Decisions
The concept was chosen to fit the input
An aperture's entire state is one scalar: how open it is. Scroll position is also one scalar.
That is not a coincidence I found afterwards — it is why this product was picked over a phone, a watch, or a pair of headphones. The 3D is not decoration bolted onto a launch page; the page is about a thing that opens and closes, and so is the scroll. Every other decision got easier because of it.
Geometry is generated at mount, not loaded
One leaf is defined as a five-point curve, extruded, and instanced twenty-seven times across three counter-rotating rings. Total asset weight: zero bytes.
This is what makes the mechanism actually open rather than cross-fade. Blade position is computed from the open value every frame, so any point between shut and wide is a real configuration, not an interpolation between two baked shapes.
The honest cost: three.js and React Three Fiber are 230 KB gzipped. That is not free and it would be dishonest to lead with "zero assets" and stop there. The comparison that matters is against the alternatives — a baked image sequence pays the library cost and several megabytes on top, and a GLTF model pays it and the mesh. Generating the geometry is what removes the second bill, not the first.
Scroll never enters React state
It is written to a ref and read inside the render loop.
Putting scroll position in state means a React render per scroll event. On a 120Hz trackpad that is a re-render every 8ms, and the frame budget is spent before three.js has drawn anything. The only thing that becomes state is the active section index, which changes five times across the whole page rather than a thousand.
Measured across a full-page programmatic scrub: 20 DOM mutations total, every one of them the six navigation dots changing aria-current and class. Nothing else in the tree moved.
The frame loop allocates nothing
Matrix, quaternion, euler and vector scratch objects are created once and reused.
Composing twenty-seven fresh Matrix4s per frame is roughly 1,600 objects a second handed to the garbage collector. That is where the intermittent stutter in scroll-driven scenes usually comes from, and it does not show up in a screenshot or in a five-second look — it shows up as a hitch every few seconds that everyone notices and nobody can reproduce on demand.
Three draw calls: one instanced mesh per ring.
Motion is damped, and frame-rate independent
Scroll position arrives quantised. Following it directly makes the mechanism twitch; easing toward it makes it feel weighted. The follow uses 1 - exp(-k·dt) rather than a fixed per-frame fraction, so a 120Hz display and a 60Hz display get the same motion rather than the faster one feeling twice as eager.
Reduced motion is handled in the render loop, because that is where it lives
The design system already collapses every CSS transition under prefers-reduced-motion. A useFrame loop is invisible to CSS — honouring the preference in a stylesheet would have looked correct and done nothing.
So the loop reads the preference itself and snaps the mechanism to each section instead of sweeping through it. The page still works, the content is all still there, the mechanism still corresponds to where you are. The continuous movement is what stops.
Same call as the agent console's trace replay in this portfolio, and for the same reason: if motion is driven by CSS the token layer already handled it; if it is driven by JavaScript, you have to.
WebGL is checked, not assumed
Blocklisted drivers, hardware acceleration switched off, and a fair number of corporate machine images will not give you a context. A launch page whose hero is a black rectangle is worse than one that never tried, so the capability is checked once and there is a static fallback behind it. The copy and the layout never depended on the canvas.
What looking at it turned up
Two problems, neither visible in the source.
The blades were sized in the abstract and rendered off-frame. At a camera distance of 4.2 with a 42° field of view, the visible half-height at the origin is about 1.6 units. Leaves 2 units long orbiting at radius 2.1 reach 4 units out. The first render was dark shapes with no readable silhouette — geometrically correct, compositionally meaningless.
A full rotation across the page put the stack exactly edge-on at 43% scroll. Three discs seen from the side, at precisely the moment the copy is explaining the mechanism. Capped at 0.62 radians.
Both are the kind of thing that only exists once something is on screen. Neither would have been caught by reading the code, and neither is a bug in any conventional sense — the code did what it said.
Result
Measured through a full-page scrub on a 120Hz display:
| Sustained | 120fps |
| Median frame | 8.3ms |
| 95th percentile | 9.1ms |
| Worst frame | 9.4ms |
| Frames over 16.9ms | 0 |
| DOM mutations while scrolling | 20 |
| 3D asset bytes | 0 |
| Draw calls | 3 |
What it demonstrates: that I can make something look expensive without it being expensive, and that I treat the performance and accessibility claims as things to measure rather than things to assert.
Concept work, built to explore scroll-scrubbed 3D and its accessibility story. Geometry is generated at runtime; the page ships no 3D assets.