MAKING-OF
How this site was built
The idea
Most portfolios are a grid of thumbnails. I wanted the front page to argue something — that the work wasn’t a list of logos, but a current that ran through them.
So the hero is an Influence Network: three streams of colored light, each carrying a population of particles, flowing out from three anchors in my career — AKQA, Meta, Apple. The particles are people; the streams are influence; where they pass close, their colors bleed. The work didn’t just ship products — it moved through networks of people, and picked up color from everything it passed.
That’s the thesis. The rest is how it got built, including the parts that fought back.
Previous lives
This isn’t the first version of this site — it’s roughly the fifth, and the through-line is that each rebuild quietly tracks whatever the frontend world was doing that year.
- WordPressPHP, a theme, a database a portfolio didn’t need
- Middleman + jQueryRuby-generated static pages, DOM by hand
- Grunt + vanilla JSno framework, better build tooling
- Reactcomponents finally worth the weight
- + Sanitycontent moves out of the code into a headless CMS
- Next.js + R3Fthis one — the influence network
Each version threw away almost all of the last one’s code and kept none of its tools. What survived every time was the content and the URLs — which is exactly why this rebuild treats content and routes as the durable layer and everything else as replaceable. I’ve replaced everything else four times already.
Two layers, not two sites
The decision that shaped everything after it: the fancy version is not the site.
Two presentation layers sit over one set of content. The base layer is semantic, server-rendered HTML — readable with JavaScript disabled or WebGL missing, and it’s what phones, reduced-motion users, and crawlers get. The canvas layer — the Three.js network — takes over only when the device and preferences can carry it. The base layer isn’t a fallback apology; it’s a complete site that happens to have a spectacular version on top. If the canvas never loads, you’d never know anything was missing.
That’s also why this page can exist: everyone can experience the thing, and the curious can read how it works.
Painting influence
The concept started as a literal graph — nodes and edges — which is to say it looked like homework. The fix was to drop topology for continuous flow: each chapter is a stream of additive lines plus particles riding a shared wave. No wires, just current.
Every frame, each point’s color is a Gaussian blend of the three anchor colors, weighted by distance to each:
const w = Math.exp(-(dx*dx + dy*dy) / falloffSq);
r += STREAM_RGB[s][0] * w; // accumulate weighted color from every anchorOn its own that’s just soft gradients. What makes it read as influence spreading is a second rule I call resistance: a stream keeps its parent color until a particle drifts near a foreign anchor, then it takes on that hue. Near home you keep your color; wander into someone else’s territory and you start to become theirs. The thesis, as a distance function.
One oddity: the bloom pass is kept mounted at intensity zero — not to glow, but because the EffectComposer allocates the HDR framebuffer the additive lines need in production. An effect turned off, kept for its side effect.
The performance war
The honest section. The network is beautiful on a strong machine and a slideshow on a weak one, so it’s gated behind a capability check — and the check took three tries to get right.
- Viewport + touch heuristicgated a new iPhone off, would’ve let a thermal-throttling iPad on
- navigator.hardwareConcurrencySafari clamps it to 4 on every iOS device — the signal is gone
- 32×32 FPS probemeasure real frame time, then decide
The first check was wrong both ways. Reading navigator.hardwareConcurrency as a horsepower proxy hit a wall too: Safari clamps it to 4 on all iOS devices to prevent fingerprinting, so a three-year-old iPad and the newest iPhone report the same number. So for the ambiguous tier where every iOS device lands, I stopped guessing and measured — a hidden 32×32 canvas runs a clear-loop for 60 frames, drops warmup, and takes the median frame time:
const median = sorted[Math.floor(sorted.length / 2)];
const fps = 1000 / median;
writeCachedPerfVerdict(fps >= 45 ? 'on' : 'off');Above 45fps the device earned the canvas. The verdict is cached for 30 days (no cores, fps, or user-agent stored), and a manual toggle always wins. It split the devices the heuristic couldn’t, with none special-cased by name. The lesson: when the platform hides the signal you want, stop inferring it and measure the thing you actually care about.
The sound
There’s an ambient generative score — a pad-and-bass bed, live plucks, and a synthesized “voice” that speaks each brand’s name on hover.
The bed started fully live in Tone.js, and it crackled — but only sometimes, the worst kind of bug. With the 3D scene running flat-out, the Web Audio render thread would occasionally starve and underrun, and an underrun is a click. Sample playback survived the contention, but rendering the loop to a sample at runtime was too slow under that same load (~20s). The fix moves the work off the user’s machine: a build-time script renders the bed to looping WAVs in headless Chromium, and ships static files the engine loops with Tone.Player — while the plucks and the hover voice stay live on top.
The voice is formant synthesis — a sawtooth through two vowel-formant bandpass filters — tracing each brand’s vowels as stressed syllables. “AKQA” spells four letters; “Meta” is “MAY-tah”. No recordings, and it only speaks on a deliberate hover.
Navigation without page loads
Click a project and its detail opens as a modal — but the canvas never unmounts, keeps flowing, and pushes in to isolate that project’s stream as the backdrop. A full navigation would tear down WebGL and audio, and rebuilding both would feel like a hiccup.
It’s the App Router’s parallel + intercepting routes: an in-app click is intercepted as a modal over the living hero, while a direct visit, refresh, or crawler gets the real server-rendered page at the same URL. One route, two presentations — the two-layer philosophy applied to navigation.
Accessibility wasn’t the last step
The motion is deliberately calm — slow eases, text beside movement, not on top of it — and prefers-reduced-motion is a hard gate: set it and you get the base layer, full stop. Contrast holds to WCAG AA against the brightest ribbon pixel, and everything navigable in the canvas is a real focusable link with a visible ring.
“Make it accessible” is usually end-of-project cleanup. On a site whose whole premise is a fancy layer some people won’t get, the un-fancy layer has to be first-class — or the premise collapses.
Colophon
Next.js 16 · React 19 · TypeScript · Three.js + React Three Fiber + drei + postprocessing · Tone.js with build-time baked WAV beds · Sanity (headless) with a custom markdown copy pipeline · SCSS modules · Vercel behind Cloudflare · Playwright.
