← All articles
February 10, 2026Web

Building a 3D Portfolio with React Three Fiber

How I built an interactive 3D hub with procedural icons, glass bubbles, and a carousel orbit — all without external 3D models.

threejsr3fnextjs3d
Share

Why 3D?

Most developer portfolios are static grids. I wanted something different — an immersive experience where each project module is a physical object you can explore.

The Stack

  • React Three Fiber — React renderer for Three.js
  • Drei — Utility components (Environment, Sparkles, etc.)
  • Postprocessing — SMAA, Bloom, Chromatic Aberration, Noise, Vignette
  • GSAP — UI panel animations
  • Zustand — State management bridging 3D and UI layers

Procedural Icons

Every icon is generated in code. No .glb, no Blender. The treble clef uses CatmullRomCurve3 with TubeGeometry. The padlock combines ExtrudeGeometry shapes. The gamepad is a group of rounded boxes and spheres.

const curve = new THREE.CatmullRomCurve3(points, false, 'catmullrom', 0.5)
const geometry = new THREE.TubeGeometry(curve, 100, 0.08, 8, false)

The trick: white cores with colored emissive glow. This gives contrast against the colored glass bubbles.

Glass Material

Each bubble uses meshPhysicalMaterial with:

  • transmission: 0.9 — see-through glass effect
  • iridescence: 0.4 — rainbow reflections
  • clearcoat: 1 — glossy surface

Critical gotcha: transmission requires an <Environment> component in the scene, otherwise the glass renders black.

Orbit Architecture

The 7 modules sit on a rotating ring (OrbitRing). The ring rotates at 0.1 rad/s (~60s per revolution) with a 20° tilt for a plunging perspective.

The camera fly-through reads the orbit angle from a shared mutable object (orbitState) — not Zustand — to avoid React re-renders at 60fps.

What I Learned

  1. Performance matters. Post-processing is expensive. Use multisampling={0} with SMAA instead of MSAA.
  2. Module positions are local to the orbit group. World positions need matrix transforms.
  3. Web Audio API has strict autoplay policies. Always resume() the AudioContext on user interaction.