← all logs
7 min read

How I built this portfolio (and the bug that hid every click)

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 devicePixelRatio is 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 IntersectionObserver and 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:

preloader.tsx — the bug
// 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.

preloader.tsx — the fix
// 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.

next log

Building multi-agent AI systems that don't fall apart