Three.js & React Three Fiber 3D Rendering

Frontend Engineering advanced Building with AI ~25 min read

Overview

Three.js is the dominant JavaScript library for WebGL-based 3D rendering in the browser. It abstracts the low-level WebGL API into a scene graph model: you create a Scene, add objects with Geometry and Material, position a Camera, and hand everything to a Renderer that draws each frame. This imperative API is powerful but verbose — creating a simple spinning cube requires 30+ lines of setup code, manual animation loops, and careful resource cleanup. For React applications, this imperative style clashes with React’s declarative component model.

React Three Fiber (R3F) bridges this gap by wrapping Three.js in a React reconciler. Every Three.js class becomes a JSX element: <mesh> creates a THREE.Mesh, <boxGeometry> creates a THREE.BoxGeometry, <meshStandardMaterial> creates a THREE.MeshStandardMaterial. The reconciler handles object creation, property updates, parent-child relationships, and disposal automatically. The same spinning cube is now 10 lines of declarative JSX, and it integrates naturally with React state, context, and hooks. R3F doesn’t wrap Three.js — it is Three.js, just expressed declaratively.

An internal component library uses React Three Fiber to create immersive landing page backgrounds: floating network node visualizations where interconnected spheres drift through 3D space, particle systems that respond to mouse movement, and gradient mesh backgrounds with subtle depth. These aren’t gratuitous effects — they visually communicate the concept of interconnected search infrastructure that the search platform provides, using 3D space to represent the distributed nature of the product.

Core Concepts

Scene Graph

Three.js organizes 3D content as a tree (scene graph). The root Scene contains child objects — meshes, lights, groups, cameras — each with a transform (position, rotation, scale) relative to its parent. Moving a parent group moves all its children. R3F maps this naturally to JSX nesting: <group position={[0, 1, 0]}><mesh>...</mesh></group>. The reconciler builds the Three.js scene graph from the component tree, and React’s diffing algorithm handles additions, removals, and updates.

The Render Loop

Three.js renders by calling renderer.render(scene, camera) every frame — typically 60 times per second via requestAnimationFrame. R3F manages this loop internally and exposes it through the useFrame hook. Code inside useFrame((state, delta) => { ... }) runs every frame with access to the clock, delta time, pointer position, and the full Three.js state. This is where animations live — rotating objects, updating particle positions, interpolating camera movement.

Geometry + Material = Mesh

A visible 3D object is a Mesh — the combination of a Geometry (the shape’s vertices, normals, and UVs) and a Material (how the surface interacts with light). Three.js provides built-in geometries (box, sphere, plane, torus) and materials (basic, standard, physical, shader). In R3F, these compose as nested elements: <mesh><sphereGeometry args={[1, 32, 32]} /><meshStandardMaterial color="hotpink" /></mesh>.

React Reconciler

R3F uses a custom React reconciler (like React DOM or React Native) that translates JSX into Three.js API calls. When you write <mesh position={[1, 2, 3]}>, the reconciler creates a new THREE.Mesh() and sets mesh.position.set(1, 2, 3). When the position prop changes, it calls mesh.position.set() again — no full re-render, no object recreation. Property names map directly to Three.js object properties, with automatic kebab-to-camel conversion and shorthand for vector/color types.

Drei — The R3F Utility Belt

@react-three/drei is a companion library providing pre-built abstractions for common 3D tasks: OrbitControls for camera interaction, Float for gentle bobbing animations, Stars and Sparkles for particle backgrounds, Text for 3D text rendering via MSDF, Environment for HDR lighting, and Html for placing DOM elements in 3D space. Drei saves hundreds of lines of boilerplate for effects that would otherwise require deep Three.js knowledge.

How It Works

Basic R3F Scene Setup

The <Canvas> component from R3F creates the WebGL context, sets up the render loop, and provides the Three.js state via context. Everything inside <Canvas> is rendered in 3D space.

import { Canvas } from "@react-three/fiber";
import { OrbitControls, Float } from "@react-three/drei";

function Scene() {
  return (
    <Canvas
      camera={{ position: [0, 0, 5], fov: 75 }}
      style={{ background: "#0f1117" }}
    >
      {/* Lighting */}
      <ambientLight intensity={0.4} />
      <pointLight position={[10, 10, 10]} intensity={1} />

      {/* A floating sphere */}
      <Float speed={2} rotationIntensity={0.5} floatIntensity={1}>
        <mesh>
          <sphereGeometry args={[1, 32, 32]} />
          <meshStandardMaterial
            color="#6c63ff"
            roughness={0.3}
            metalness={0.8}
          />
        </mesh>
      </Float>

      {/* Camera controls */}
      <OrbitControls enableZoom={false} autoRotate autoRotateSpeed={0.5} />
    </Canvas>
  );
}

Animated Network Node Visualization

The component library’s landing page renders a network of interconnected nodes floating in 3D space. Each node is a sphere, and connections between nearby nodes are drawn as lines. The entire system drifts and rotates slowly, with nodes gently bobbing via sine-wave offsets.

import { useRef, useMemo } from "react";
import { useFrame } from "@react-three/fiber";
import * as THREE from "three";

interface Node {
  position: THREE.Vector3;
  velocity: THREE.Vector3;
  phase: number;
}

function NetworkNodes({ count = 40, radius = 8 }: {
  count?: number;
  radius?: number;
}) {
  const meshRef = useRef<THREE.InstancedMesh>(null);
  const linesRef = useRef<THREE.LineSegments>(null);
  const dummy = useMemo(() => new THREE.Object3D(), []);

  // Generate random node positions
  const nodes = useMemo<Node[]>(() =>
    Array.from({ length: count }, () => ({
      position: new THREE.Vector3(
        (Math.random() - 0.5) * radius,
        (Math.random() - 0.5) * radius,
        (Math.random() - 0.5) * radius
      ),
      velocity: new THREE.Vector3(
        (Math.random() - 0.5) * 0.01,
        (Math.random() - 0.5) * 0.01,
        (Math.random() - 0.5) * 0.01
      ),
      phase: Math.random() * Math.PI * 2,
    })),
    [count, radius]
  );

  useFrame(({ clock }) => {
    const t = clock.getElapsedTime();

    // Update node positions with drift + sine bobbing
    nodes.forEach((node, i) => {
      node.position.add(node.velocity);
      const bobY = Math.sin(t * 0.5 + node.phase) * 0.02;
      node.position.y += bobY;

      // Wrap around boundaries
      ["x", "y", "z"].forEach((axis) => {
        const a = axis as "x" | "y" | "z";
        if (Math.abs(node.position[a]) > radius / 2) {
          node.velocity[a] *= -1;
        }
      });

      // Update instanced mesh matrix
      dummy.position.copy(node.position);
      dummy.updateMatrix();
      meshRef.current?.setMatrixAt(i, dummy.matrix);
    });

    if (meshRef.current) {
      meshRef.current.instanceMatrix.needsUpdate = true;
    }

    // Update connection lines between nearby nodes
    if (linesRef.current) {
      const positions: number[] = [];
      const connectionDistance = radius * 0.35;

      for (let i = 0; i < nodes.length; i++) {
        for (let j = i + 1; j < nodes.length; j++) {
          const dist = nodes[i].position.distanceTo(nodes[j].position);
          if (dist < connectionDistance) {
            positions.push(
              nodes[i].position.x, nodes[i].position.y, nodes[i].position.z,
              nodes[j].position.x, nodes[j].position.y, nodes[j].position.z
            );
          }
        }
      }

      const geom = linesRef.current.geometry;
      geom.setAttribute(
        "position",
        new THREE.Float32BufferAttribute(positions, 3)
      );
      geom.attributes.position.needsUpdate = true;
    }
  });

  return (
    <group rotation={[0, 0, Math.PI / 6]}>
      {/* Node spheres (instanced for performance) */}
      <instancedMesh ref={meshRef} args={[undefined, undefined, count]}>
        <sphereGeometry args={[0.06, 16, 16]} />
        <meshStandardMaterial
          color="#6c63ff"
          emissive="#6c63ff"
          emissiveIntensity={0.5}
        />
      </instancedMesh>

      {/* Connection lines */}
      <lineSegments ref={linesRef}>
        <bufferGeometry />
        <lineBasicMaterial
          color="#6c63ff"
          transparent
          opacity={0.15}
        />
      </lineSegments>
    </group>
  );
}

Mouse-Tracked Particle System

Particles that respond to the mouse pointer create an interactive feel. R3F provides the pointer position normalized to [-1, 1] via useFrame’s state argument.

import { useRef, useMemo } from "react";
import { useFrame } from "@react-three/fiber";
import * as THREE from "three";

function MouseParticles({ count = 500 }: { count?: number }) {
  const pointsRef = useRef<THREE.Points>(null);

  const particles = useMemo(() => {
    const positions = new Float32Array(count * 3);
    const speeds = new Float32Array(count);

    for (let i = 0; i < count; i++) {
      positions[i * 3] = (Math.random() - 0.5) * 10;
      positions[i * 3 + 1] = (Math.random() - 0.5) * 10;
      positions[i * 3 + 2] = (Math.random() - 0.5) * 10;
      speeds[i] = 0.2 + Math.random() * 0.8;
    }

    return { positions, speeds };
  }, [count]);

  useFrame(({ pointer, clock }) => {
    if (!pointsRef.current) return;
    const positions = pointsRef.current.geometry.attributes
      .position as THREE.BufferAttribute;
    const arr = positions.array as Float32Array;
    const t = clock.getElapsedTime();

    // Mouse influence (normalized -1 to 1 → world coordinates)
    const mouseX = pointer.x * 3;
    const mouseY = pointer.y * 3;

    for (let i = 0; i < count; i++) {
      const ix = i * 3;
      const speed = particles.speeds[i];

      // Gentle drift
      arr[ix + 1] += Math.sin(t * speed + i) * 0.002;

      // Attract toward mouse
      const dx = mouseX - arr[ix];
      const dy = mouseY - arr[ix + 1];
      const dist = Math.sqrt(dx * dx + dy * dy);

      if (dist < 3) {
        const force = (3 - dist) / 3 * 0.01 * speed;
        arr[ix] += dx * force;
        arr[ix + 1] += dy * force;
      }
    }

    positions.needsUpdate = true;
  });

  return (
    <points ref={pointsRef}>
      <bufferGeometry>
        <bufferAttribute
          attach="attributes-position"
          count={particles.positions.length / 3}
          array={particles.positions}
          itemSize={3}
        />
      </bufferGeometry>
      <pointsMaterial
        color="#00d4aa"
        size={0.03}
        transparent
        opacity={0.6}
        sizeAttenuation
      />
    </points>
  );
}

Practical Applications

The component library’s landing page hero section uses a NetworkNodes component that renders 40-60 floating spheres connected by translucent lines. The visualization communicates the search platform’s distributed search infrastructure visually — nodes represent search clusters, connections represent data replication paths. The animation runs at 60fps using instanced meshes (a single draw call for all spheres) and updates connection lines only between nodes within a proximity threshold.

The vector space visualization page uses React Three Fiber to render a 3D scatterplot of document embeddings. Each point represents a document in the search index, colored by cluster membership. Users can orbit the camera to explore spatial relationships between documents, and hovering a point shows the document title via Drei’s &lt;Html> component (which places a DOM tooltip in 3D space, automatically handling perspective projection and occlusion). This visualization connects to the same WebSocket backend used by the Deck.gl 2D scatterplot, but renders in full 3D for more intuitive exploration of high-dimensional embedding spaces.

A secondary use case is the loading animation for long-running search operations. Rather than a spinner, a small R3F canvas shows a pulsing sphere with orbiting particles that accelerate as search progress increases. The animation state is driven by React state (search progress percentage), demonstrating how R3F naturally integrates with application state management — the 3D scene reacts to React state changes just like any other component.

Implementation Patterns

Performance: Instanced Meshes

Rendering many identical objects (particles, nodes, data points) as individual meshes creates one draw call per object, destroying GPU performance. InstancedMesh renders all instances in a single draw call by providing a per-instance transform matrix.

// Bad: 1000 draw calls
function SlowNodes({ positions }: { positions: [number, number, number][] }) {
  return (
    <>
      {positions.map((pos, i) => (
        <mesh key={i} position={pos}>
          <sphereGeometry args={[0.1, 16, 16]} />
          <meshStandardMaterial color="#6c63ff" />
        </mesh>
      ))}
    </>
  );
}

// Good: 1 draw call
function FastNodes({ positions }: { positions: [number, number, number][] }) {
  const meshRef = useRef<THREE.InstancedMesh>(null);
  const dummy = useMemo(() => new THREE.Object3D(), []);

  useEffect(() => {
    positions.forEach((pos, i) => {
      dummy.position.set(...pos);
      dummy.updateMatrix();
      meshRef.current?.setMatrixAt(i, dummy.matrix);
    });
    if (meshRef.current) {
      meshRef.current.instanceMatrix.needsUpdate = true;
    }
  }, [positions]);

  return (
    <instancedMesh ref={meshRef} args={[undefined, undefined, positions.length]}>
      <sphereGeometry args={[0.1, 16, 16]} />
      <meshStandardMaterial color="#6c63ff" />
    </instancedMesh>
  );
}

Lazy Loading the Canvas

Three.js and R3F add ~150KB to the bundle. For landing pages where the 3D hero is below the fold or non-essential, lazy-load the entire canvas to avoid blocking initial page load.

import { Suspense, lazy } from "react";

// Lazy-load the entire 3D scene
const HeroScene = lazy(() => import("./HeroScene"));

function LandingPage() {
  return (
    <div className="relative h-screen">
      {/* 3D background loads after page paint */}
      <div className="absolute inset-0 -z-10">
        <Suspense fallback={<div className="bg-slate-950 h-full" />}>
          <HeroScene />
        </Suspense>
      </div>

      {/* Content renders immediately */}
      <div className="relative z-10 flex items-center justify-center h-full">
        <h1 className="text-5xl font-bold text-white">
          Search, Reimagined
        </h1>
      </div>
    </div>
  );
}

Common Pitfalls

Further Reading