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.
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.
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.
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>.
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.
@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.
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>
);
}
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>
);
}
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>
);
}
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 <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.
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>
);
}
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>
);
}
useFrame runs 60 times per second. Creating new THREE.Vector3() or new THREE.Color() inside it causes 60 allocations/second that trigger garbage collection pauses, causing visible frame drops. Pre-allocate reusable objects with useMemo outside the frame loop and mutate them inside.useRef + useEffect) must be disposed in the effect cleanup: return () => { geometry.dispose(); material.dispose(); }. Leaking GPU memory causes progressive slowdown and eventual WebGL context loss.setState inside useFrame triggers a React re-render 60 times per second, which is catastrophically expensive. Use refs for values that change every frame (positions, rotations) and only use state for values that change occasionally (visibility toggles, mode switches). The useFrame callback has access to refs without triggering re-renders.<mesh> is a separate draw call. Rendering 500+ individual meshes will drop below 60fps on mobile GPUs. Use InstancedMesh for repeated geometries, merge static geometries with BufferGeometryUtils.mergeGeometries(), and use <points> for particle systems instead of individual sphere meshes.