The legacy system
The first version of this site's navigation was a school of manta rays swimming through space — built in vanilla Three.js 0.128, served inside an iframe, and held together with optimism. It looked good. It was also a museum of anti-patterns:
- CPU vertex animation. Every frame, JavaScript looped over roughly 7,800 vertices, computed a wing-flap deformation, wrote the results into the position buffer, and re-uploaded it to the GPU. Per manta. The main thread spent its life doing work a vertex shader does for free.
- The iframe wall. The 3D scene lived in a separate document, so every interaction — navigation clicks, hover states, locale changes — crossed a
postMessagebridge. Internationalizing the HUD meant serializing translation payloads into the iframe and hoping both sides agreed on the schema. - No instancing, no tiers, no accessibility. Each creature was its own mesh and draw call. The scene ran the same workload on a four-year-old phone as on a desktop GPU. And being a WebGL canvas in an iframe, it was completely invisible to keyboard and screen-reader users.
The rebuild had one rule: the new system had to live inside the React tree as a first-class citizen — same router, same i18n context, same state — and the GPU had to do the animating.
GPU wing-flap: animation as a shader
The signature wing-flap moved from a JavaScript vertex loop into a GLSL vertex shader. Each vertex displaces as a function of its distance from the body's spine, a phase uniform, and per-instance attributes — flap frequency, amplitude, phase offset, and size — so every manta in the school beats its wings on its own rhythm without any per-creature JavaScript.
Because the deformation is computed from the rest pose every frame, there is no geometry upload at all after initialization. The entire school animates from a handful of uniform updates.
Three draw calls for a living scene
The ~47 creatures in the scene render as instanced meshes — the full school costs 3 draw calls (one per creature archetype). Per-instance attribute buffers carry each individual's flight parameters, tint, and animation phase, so instancing doesn't mean uniformity: every creature is distinct, but the GPU sees three batched draws.
Layout is generated by a seeded PRNG, which sounds like a small detail but does a lot of work: the school is "random" yet deterministic, so every visitor sees the same composition, visual regressions are diffable, and hydration never mismatches.
Iridescence and selective bloom
The mantas' rims carry an iridescent fresnel term — view-angle-dependent color that shifts as they bank. The trick is that the fresnel rim is pushed above luminance 1.0, and the bloom pass thresholds at exactly 1.0. The result is selective bloom with no extra render targets or layer juggling: only the iridescent rims glow, because only they cross the threshold.
Flight that feels like flight
Creatures follow curved paths, but the motion sells because of banking: acceleration is estimated by finite differences along the path, and each creature rolls into its turns proportionally to lateral acceleration — the way a real wing-borne body does. It is a few lines of math that separates "objects on rails" from "things that are flying."
Performance as a system, not a hope
Device capability is detected up front with detect-gpu and mapped to quality tiers (geometry density, bloom resolution, creature count). On top of that sits a runtime fps governor that watches frame times and steps the tier down if a device underdelivers its benchmark class — covering thermal throttling, browser tabs fighting for the GPU, and benchmarks that lie.
Accessibility in a WebGL scene
Two things most 3D sites skip:
prefers-reduced-motionswitches the scene to a beauty-shot mode — a composed, nearly still rendering rather than a swirling school — instead of just ignoring the preference.- Keyboard navigation works through hidden DOM buttons overlaid on the scene graph: each navigable destination in the 3D space has a real focusable element, so Tab/Enter drive the WebGL camera the same way a pointer does. Screen readers see a normal navigation; sighted keyboard users see the scene respond.
What it replaced
The old iframe, the postMessage i18n bridge, the per-frame vertex uploads, and the duplicate state layer are all gone. The manta engine is now a react-three-fiber component tree reading the same zustand store and next-intl context as the rest of the site — one application, three draw calls, sixty frames.