How I rebuilt this portfolio with a single global R3F canvas, GSAP scroll-scrubbed reveals, Lenis smooth scroll, and a shader page-transition curtain — without tanking LCP.

Why bother
Most developer portfolios look the same: shadcn cards, Inter, a hero with a typewriter. Mine did too. This post is the case study of rebuilding it as a dark, motion-first WebGL experience that still passes Lighthouse.
The stack
- three.js via
@react-three/fiber+drei+@react-three/postprocessing - GSAP with
ScrollTriggerfor scroll-scrubbed timelines - Lenis for inertial smooth scroll, bridged into GSAP's ticker
- zustand as the single source of truth between DOM and GL
One canvas, many scenes
The key architectural choice: one global <Canvas> mounted in the root layout, with drei's <View> tunnels per section. Hero particles, ambient flow-field noise, the wireframe sphere on Skills, the torus knot on Services, the contact halo, and the page-transition curtain all share one WebGL context. Cheaper, smoother, and the curtain can persist across route changes.
Scroll-linked reveals
Every <Display> heading splits into per-word spans with overflow-clip masks. A ScrollTrigger with scrub: 0.6 animates them from y: 110% to 0 — words appear as you scroll past, not just on enter.
The shader page transition
App Router's template.tsx remounts on each navigation. A <TransitionLink> intercepts clicks, runs an exit timeline that ramps a uniform from 0→1 in the curtain shader (noise-displaced wipe + RGB split), then calls router.push. Template remounts and runs the inverse.
Performance posture
The Canvas is next/dynamic with ssr: false, lazy-mounted after requestIdleCallback. LCP is the server-rendered display headline, not the canvas. DPR clamped to 1.75. frameloop="demand" when it's safe.
What I'd change
This is a stub — full breakdown coming soon. If you want the source while I write it, ping me.
Full-Stack Developer specializing in Ruby on Rails and Next.js