Skip to main content

First Steps with Three.js and React Three Fiber

I wanted the homepage to feel like something. Not just a name and a title, but something that hints at the kind of work I want to do.

The answer turned out to be a game controller assembled from geometric primitives, floating in a starfield.

Why Three.js

I’d been aware of Three.js for a while. The docs are dense, the API surface is wide, and the gap between “I understand this” and “I can build something cool with this” felt enormous.

React Three Fiber changes that equation a bit. Instead of imperative scene management you get a declarative JSX tree. Lights, cameras and meshes are all components. If you already know React, the mental model maps over surprisingly well.

<Canvas camera={{ position: [0, 0, 8], fov: 45 }}>
  <ambientLight intensity={0.3} />
  <pointLight position={[0, 0, 8]} intensity={2.5} />
  <GameController />
</Canvas>

Building the controller

The controller is 12 separate meshes: two body halves, two grips, joysticks, a D-pad, face buttons, a center button and two bumpers. Each one is a Three.js primitive: BoxGeometry, CapsuleGeometry, CylinderGeometry, DodecahedronGeometry.

The assembly animation was the interesting part. On load, each piece starts scattered in 3D space and travels to its assembled position over ~2.4 seconds using a GSAP tween:

gsap.to(assemblyProgress, {
  current: 1,
  duration: 2.4,
  ease: 'power3.out',
  delay: 0.3,
});

Every frame, each mesh interpolates between its scattered position and assembled position based on the progress value:

const eff = assemblyProgress.current * (1 - scrollProgress.current);
mesh.position.x = lerp(scattered.x, assembled.x, eff);

The scroll part means the controller disassembles as you scroll down. Same formula, different inputs.

What I learned

Refs over state. Putting assemblyProgress in a React ref instead of state means GSAP can tween it without triggering re-renders. useFrame reads the ref every frame natively. This is the key pattern for GSAP + R3F coexistence.

Geometry is cheap, materials less so. Each mesh sharing a material instance is worth doing. I started with separate material objects and the diff in draw calls was noticeable.

R3F v8 needs React 18. React 19 breaks it due to internal reconciler changes. Pin your React version and move on.


It took two days of iteration to get something I was happy with. Probably worth it. Every time I land on the page it still feels a bit surprising.

_