This site is a Next.js App Router app with an unreasonable amount of WebGL for a portfolio: a particle field of roughly 80,000 points that morphs between forms, a scroll-driven wireframe companion, and shader panels behind every project. The fun part wasn't building the effects — it was keeping them from tanking the experience on a mid-range phone, and one bug that made the whole site look completely broken while being, technically, perfectly rendered.
Making 80k particles cheap enough
A particle system is only free until it isn't. Three rules kept the frame budget under control:
- Gate on the GPU. I use
detect-gputo read the device's tier and simply don't mount the heavy scenes on tier-0 hardware or phones. No WebGL beats janky WebGL. - Cap the pixel ratio. Rendering at the full retina
devicePixelRatiois the fastest way to quadruple your fragment cost for no visible gain. I clamp it to[1, 1.5]. - Pause what you can't see. Every project panel canvas is wrapped in an
IntersectionObserverand its render loop is suspended while off-screen, so five shader canvases never run at once.
The other silent killer was reading layout in the animation loop. Any scrollHeight or getBoundingClientRect() call inside useFrame forces a synchronous reflow every frame. I cache those values and refresh them from a ResizeObserver instead.
The bug that hid every click
At one point every button on the site stopped working. Not throwing — just dead. No console errors, the page looked pixel-perfect, and the elements were all in the DOM. The culprit was a hydration mismatch.
My preloader (the "decrypting identity" intro) read sessionStorage in its useState initializer to skip itself on repeat visits:
// Server renders done=false (no sessionStorage on the server).
// Client's first render reads the key and returns done=true.
// The two trees don't match -> hydration fails.
const [done, setDone] = useState(
() => !!sessionStorage.getItem("divi:loaded")
);When hydration fails, React discards the server HTML and re-renders on the client. During that thrash the preloader's exit animation never ran, so its full-screen z-index: 200 overlay — with pointer-events: auto— stayed on top of everything, swallowing every click. The page under it was fine; you just couldn't reach it.
The fix is boring, which is the point: match the server.
// Always start false so SSR and first client render agree.
const [done, setDone] = useState(false);
useEffect(() => {
// Skip on repeat visits AFTER hydration, where it's safe.
if (sessionStorage.getItem("divi:loaded")) setDone(true);
}, []);Hydration errors aren't cosmetic. A mismatch can silently break interactivity across the entire page while everything still looks right. If clicks die with no error, check the dev overlay for a recoverable hydration warning first.
The same class of bug bit me twice more: WebGL components reading a ref during render, and an ssr:false dynamic component rendering before mount. The general fix is the same — anything that differs between server and client belongs in an effect, not in render.
Mobile: stop being clever
Desktop uses Lenis for smooth momentum scrolling. On touch devices that was actively harmful — Lenis intercepts touch events, which broke tap targets and link navigation. The fix was to detect (pointer: coarse) and bypass Lenis entirely, letting native scroll do its job. I also disable the WebGL companion on phones, ship a real hamburger drawer instead of a hidden desktop nav, and made the chat panel fluid so it never overflows a 320px screen.
What I'd tell someone starting one
Build the performance gates first— GPU tiering, reduced-motion handling, and off-screen pausing are much harder to retrofit. And treat SSR/client parity as a correctness property, not a nice-to-have: the scariest bugs here don't crash, they just quietly stop working.