... project dossier

The Kinetic Portfolio: A Technical Deep Dive into a 3D WebGL Portfolio Site

... case study

Full Record

[long form]

Most portfolio sites are flat. You click a link, a new page loads, you scroll. It works, but it is forgettable.

What if your portfolio felt like a cinematic experience? What if navigation was a camera journey through a 3D space, with ribbons blazing a path between sections and content floating on glass panels?

That is The Kinetic Portfolio — an immersive 3D WebGL portfolio site inspired by the Colin McRae: DiRT (2007) navigation UI.

Today, we are going under the hood.

The Core Architecture

Persistent Canvas

The most fundamental design decision: the 3D scene never unmounts.

// App.tsx
<BrowserRouter>
  <Canvas frameloop="demand">
    <Scene />
    <RouteSync />
  </Canvas>
</BrowserRouter>

App.tsx mounts a single <Canvas> at the root inside <BrowserRouter>. The <Scene> component never unmounts on route change. Route changes only update Zustand state (currentRoute), which drives camera waypoints and panel visibility.

This means:

  • No loading spinners between routes
  • No texture re-uploads
  • No shader recompilation
  • Smooth camera transitions between every section

Single Source of Truth

The entire app state lives in one Zustand store (src/store/index.ts). This includes:

  • SCENE_LAYOUT — exact 3D camera positions, targets, and panel transforms for each route
  • currentRoute — the active route ID
  • transitionStatusidle | transitioning
  • hoveredElement — for hover states
  • activeItemId — for panel selection

Debug state lives in a separate debugSlice that is merged into the same store, exposing free-camera mode and waypoint capture tools.

RouteSync Bridge

RouteSync is a small component that bridges React Router URL changes to Zustand state:

function RouteSync() {
  const location = useLocation();
  const { setRoute, setActiveItemId } = useStore();
  useEffect(() => {
    const route = pathToRoute(location.pathname);
    setRoute(route);
  }, [location.pathname]);
}

When the URL changes, RouteSync updates currentRoute in the store. The camera rig watches currentRoute and animates to the corresponding waypoint. Content updates reactively.

The Ribbon Transition System

The ribbon transition is the signature feature — a volumetric red arrow that blazes a path between sections.

How It Works

  1. User clicks a navigation item
  2. transitionStatus switches to transitioning
  3. React Router navigates to the new path
  4. The Ribbon component mounts and GSAP animates uProgress from 0→1
  5. A custom GLSL shader fades uOpacity during the transition
  6. On onComplete, transitionStatus resets to idle

The Shader

The ribbon uses a custom vertex and fragment shader. The vertex shader displaces vertices along a bezier curve path, and the fragment shader applies a gradient with glow:

// Fragment shader (simplified)
uniform float uProgress;
uniform float uOpacity;
uniform vec3 uColor;

void main() {
  float glow = smoothstep(0.0, 1.0, uv.x);
  vec3 color = mix(uColor, vec3(1.0), glow * 0.5);
  gl_FragColor = vec4(color, uOpacity * (1.0 - uv.y));
}

Performance Guard

Pointer events are disabled while transitionStatus !== idle. This prevents users from clicking through multiple sections during a transition and stacking animations.

Post-Processing Pipeline

The visual polish comes from a multi-pass post-processing stack using the postprocessing library:

<PostProcessing>
  <SMAA />
  <DepthOfField focus={dofFocus} />
  <Bloom luminanceThreshold={0.9} />
  <MotionBlur intensity={0.5} />
</PostProcessing>
  • SMAA — Subpixel Morphological Anti-Aliasing for clean edges
  • Depth of Field — Focuses on the active panel, blurs background elements
  • Bloom — Adds glow to bright elements (ribbons, highlights)
  • Motion Blur — Custom shader pass that creates a subtle trail effect during camera movement

Camera Rig

The camera system is the heart of the experience. It uses GSAP for waypoint-driven camera transitions:

// Camera waypoint definition
const WAYPOINTS = {
  home: { position: [0, 2, 10], target: [0, 0, 0] },
  projects: { position: [5, 3, 8], target: [3, 1, 0] },
  blog: { position: [-5, 2, 8], target: [-3, 0, 0] },
  // ...
};

Idle Camera Drift

When idle, the camera drifts with layered sine waves plus mouse parallax:

function useIdleDrift(time: number) {
  const x = Math.sin(time * 0.3) * 0.2 + Math.sin(time * 0.7) * 0.1;
  const y = Math.cos(time * 0.5) * 0.15;
  return [x, y, 0];
}

This creates a subtle breathing effect that makes the scene feel alive without being distracting.

Adaptive Quality

The portfolio monitors frame time and automatically degrades DPR (device pixel ratio) when performance drops:

// If frame time exceeds 16.67ms (60 FPS budget)
if (frameTime > 16.67) {
  setDPR(Math.max(1, currentDPR - 0.1));
}

This ensures smooth performance even on lower-end hardware. The target is 60 FPS on an M1 MacBook Air with fewer than 80 draw calls.

Content Pipeline

The portfolio supports two content modes:

  1. Fixture mode (USE_FIXTURES = true) — All content served from src/data/content.ts for development
  2. Live mode — Content fetched from WordPress via GraphQL using @graphql-codegen

Mobile Experience

The portfolio detects WebGL support and viewport size:

  • Desktop — Full 3D experience with DesktopExperience component (lazy-loaded)
  • Mobile — CSS-based fallback with MobileExperience component

Build-Time Prerendering

SEO is handled through build-time prerendering with dynamic metadata and JSON-LD structured data. Each route generates a static HTML shell with proper <title>, <meta> tags, and structured data for search engines.

Design System

The portfolio follows strict design conventions:

  • 0px border-radius everywhere — sharp, geometric aesthetic
  • No 1px solid dividers — uses shadows and depth instead
  • CSS variables for all colors (--kv-accent: #e90000, --kv-bg: #FCF9F2)
  • Panel chrome — consistent header, tabs, options list, and status bar via PanelChrome component

Performance Budget

Target: 60 FPS on an M1 MacBook Air with fewer than 80 draw calls. Route transitions complete in approximately 350ms.

Lessons Learned

What Worked Well

  1. Persistent canvas — The single biggest performance win. No scene teardown/rebuild between routes.
  2. Zustand for state — Simple, fast, and easy to debug. No provider nesting.
  3. GSAP for animations — Reliable timing, easy to chain, great onComplete callbacks.
  4. Adaptive quality — Graceful degradation on lower-end hardware.

What Was Hard

  1. Camera transitions — Getting the easing right took a lot of iteration. Too fast feels jarring, too slow feels sluggish.
  2. Post-processing tuning — Bloom and DOF can easily make the scene look washed out. Finding the sweet spot took time.
  3. Mobile fallback — Creating a compelling 2D experience that still feels kinetic was challenging.

The Tech Stack

  • React 19 + React Router 7
  • Three.js 0.183 + React Three Fiber 9 + drei 10
  • GSAP 3.14 for camera animations
  • Zustand 5 for state management
  • postprocessing 6 for post-processing pipeline
  • Vite 5 for the build tool
  • GLSL shaders for custom effects
  • graphql-request + @graphql-codegen for WordPress content