Web Development · 3D and WebGL
Three.js and React Three Fiber: 3D on the Web Without the Pain
Three.js makes WebGL accessible. React Three Fiber makes Three.js feel like React. Together they're the fastest path to interactive 3D on the web. Here's how to actually use them.
Anurag Verma
7 min read
Sponsored
WebGL is a low-level graphics API. Using it directly means writing shader code, managing buffers, handling context loss, and dealing with a significant amount of boilerplate before anything appears on screen. Three.js sits on top of WebGL and turns that into something workable. React Three Fiber (R3F) sits on top of Three.js and turns it into declarative JSX.
If you want interactive 3D on the web (product configurators, data visualizations, portfolio pieces, game-adjacent UI), this stack is the fastest way to get there without a computer graphics background.
The Mental Model
Three.js works with three core concepts:
Scene: A graph of objects. Everything you render lives in the scene.
Camera: The point of view. Typically a PerspectiveCamera (like a real camera with field of view) or OrthographicCamera (no perspective distortion, useful for technical visualizations).
Renderer: Takes the scene and camera and produces pixels on a canvas.
You add objects to the scene, position the camera, and call renderer.render(scene, camera) each frame.
React Three Fiber wraps all of this. The canvas becomes a component, scene graph objects become JSX elements, and the render loop runs automatically.
Setup
npm install three @react-three/fiber @react-three/drei
npm install -D @types/three
three is the core library. @react-three/fiber is the React renderer. @react-three/drei is a collection of helpers and abstractions that handle the 90% of use cases you’ll encounter.
A Minimal Scene
import { Canvas } from '@react-three/fiber'
import { OrbitControls } from '@react-three/drei'
function Box() {
return (
<mesh>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="royalblue" />
</mesh>
)
}
export default function App() {
return (
<div style={{ width: '100vw', height: '100vh' }}>
<Canvas camera={{ position: [3, 3, 3] }}>
<ambientLight intensity={0.5} />
<directionalLight position={[10, 10, 5]} intensity={1} />
<Box />
<OrbitControls />
</Canvas>
</div>
)
}
<Canvas> creates a WebGL context and a render loop. <mesh> is a Three.js object that combines geometry and material. <boxGeometry> defines the shape, <meshStandardMaterial> defines how it responds to light. <OrbitControls> from drei adds mouse-controlled camera rotation for free.
Interaction
import { useRef, useState } from 'react'
import { useFrame } from '@react-three/fiber'
function RotatingBox() {
const meshRef = useRef()
const [hovered, setHovered] = useState(false)
const [clicked, setClicked] = useState(false)
// Runs every frame (~60 times per second)
useFrame((state, delta) => {
meshRef.current.rotation.y += delta * (clicked ? 2 : 0.5)
})
return (
<mesh
ref={meshRef}
onClick={() => setClicked(c => !c)}
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
scale={hovered ? 1.2 : 1}
>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color={hovered ? 'hotpink' : 'royalblue'} />
</mesh>
)
}
useFrame is the R3F hook for per-frame updates. It receives state (the renderer, camera, scene) and delta (time since last frame in seconds). Using delta for animations makes them frame-rate-independent.
Pointer events (onClick, onPointerOver) are ray-cast correctly against 3D geometry, not a 2D bounding box.
Loading 3D Models
Real product work uses glTF models, not procedural geometry. The useGLTF hook from drei handles loading:
import { useGLTF } from '@react-three/drei'
import { Suspense } from 'react'
function ProductModel({ url }) {
const { scene } = useGLTF(url)
return <primitive object={scene} />
}
export default function ProductViewer() {
return (
<Canvas camera={{ position: [0, 2, 5], fov: 45 }}>
<ambientLight intensity={0.8} />
<directionalLight position={[5, 5, 5]} castShadow />
<Suspense fallback={null}>
<ProductModel url="/models/chair.glb" />
</Suspense>
<OrbitControls enableZoom={true} enablePan={false} />
</Canvas>
)
}
Wrap in Suspense. useGLTF suspends while loading, so swap fallback={null} for a loading indicator if you want better UX.
Pre-generate types for your models with npx gltfjsx your-model.glb. This creates a typed React component from the GLTF file, so you get autocomplete for animation clips and node names.
Materials and Lighting
The material you use determines how an object responds to light:
// Flat color, not affected by lighting
<meshBasicMaterial color="red" />
// Standard PBR material (recommended for most use cases)
<meshStandardMaterial
color="#ffffff"
roughness={0.4} // 0 = mirror, 1 = fully rough
metalness={0.7} // 0 = plastic, 1 = metal
/>
// Higher quality, more expensive PBR
<meshPhysicalMaterial
color="#c0c0c0"
roughness={0.1}
metalness={0.9}
clearcoat={1} // Car paint effect
clearcoatRoughness={0.1}
/>
// Texture-mapped material
<meshStandardMaterial map={colorTexture} normalMap={normalTexture} />
Three light types you’ll use constantly:
<ambientLight intensity={0.3} /> // Flat fill, no shadows
<directionalLight
position={[5, 10, 5]}
intensity={1}
castShadow
shadow-mapSize={[2048, 2048]}
/>
<pointLight position={[0, 3, 0]} intensity={2} color="orange" />
Environment Maps for Realistic Reflection
The fastest way to make metallic or glass materials look convincing is an environment map (a 360-degree image that objects reflect).
import { Environment } from '@react-three/drei'
function Scene() {
return (
<>
<Environment preset="studio" />
<mesh>
<sphereGeometry args={[1, 64, 64]} />
<meshStandardMaterial
color="silver"
metalness={1}
roughness={0}
envMapIntensity={1}
/>
</mesh>
</>
)
}
@react-three/drei ships with presets: city, dawn, forest, lobby, night, park, studio, sunset, warehouse. For custom environments, pass a path to an HDR or EXR file.
Performance Considerations
3D in the browser has different performance characteristics than regular UI work.
Geometry: Reduce polygon count where possible. A sphere with 64 segments looks identical to one with 512 at most sizes, but the latter has 8x more vertices to process. Use LOD (Level of Detail) for complex scenes.
Instancing: If you’re rendering many copies of the same geometry, use <instancedMesh>. A scene with 10,000 individual meshes runs at single digits FPS. The same scene with instancing can run at 60.
import { useRef, useEffect } from 'react'
import * as THREE from 'three'
function Particles({ count = 10000 }) {
const meshRef = useRef()
useEffect(() => {
const matrix = new THREE.Matrix4()
for (let i = 0; i < count; i++) {
matrix.setPosition(
(Math.random() - 0.5) * 20,
(Math.random() - 0.5) * 20,
(Math.random() - 0.5) * 20
)
meshRef.current.setMatrixAt(i, matrix)
}
meshRef.current.instanceMatrix.needsUpdate = true
}, [count])
return (
<instancedMesh ref={meshRef} args={[null, null, count]}>
<sphereGeometry args={[0.05, 6, 6]} />
<meshStandardMaterial color="white" />
</instancedMesh>
)
}
Texture compression: Use KTX2/Basis Universal textures instead of PNG/JPEG for textures in production. They’re smaller and GPU-native. drei’s KTX2Loader handles decompression.
Frustum culling: Three.js automatically skips rendering objects outside the camera’s view. Make sure you’re not disabling this (frustumCulled={false}) unless you have a reason.
Performance Monitor: drei includes a <Perf /> component that overlays FPS, memory, and draw calls during development. Add it while profiling:
import { Perf } from 'r3f-perf'
// Only in development
{process.env.NODE_ENV === 'development' && <Perf />}
A Practical Use Case: Product Configurator
Here’s a pattern for a product configurator where users can change colors:
import { useState } from 'react'
import { Canvas } from '@react-three/fiber'
import { useGLTF, OrbitControls, Environment } from '@react-three/drei'
const COLORS = {
black: '#1a1a1a',
white: '#f5f5f5',
red: '#e53e3e',
blue: '#3182ce',
}
function ChairModel({ color }) {
const { nodes, materials } = useGLTF('/models/chair.glb')
return (
<group>
{/* Assume the chair has 'seat' and 'legs' nodes */}
<mesh geometry={nodes.seat.geometry}>
<meshStandardMaterial
color={color}
roughness={0.5}
metalness={0.1}
/>
</mesh>
<mesh
geometry={nodes.legs.geometry}
material={materials.chrome}
/>
</group>
)
}
export function ProductConfigurator() {
const [color, setColor] = useState(COLORS.black)
return (
<div>
<div style={{ width: '100%', height: '500px' }}>
<Canvas camera={{ position: [0, 1, 3], fov: 50 }}>
<Environment preset="studio" />
<Suspense fallback={null}>
<ChairModel color={color} />
</Suspense>
<OrbitControls
enablePan={false}
minDistance={2}
maxDistance={5}
/>
</Canvas>
</div>
<div style={{ display: 'flex', gap: '8px', padding: '16px' }}>
{Object.entries(COLORS).map(([name, value]) => (
<button
key={name}
onClick={() => setColor(value)}
style={{
width: 40,
height: 40,
borderRadius: '50%',
background: value,
border: color === value ? '3px solid #000' : '2px solid transparent',
cursor: 'pointer',
}}
aria-label={name}
/>
))}
</div>
</div>
)
}
When to Use It
Three.js + R3F makes sense for:
- Product configurators and 3D previews in e-commerce
- Data visualization where spatial relationships matter (network graphs, 3D scatter plots, globe-based maps)
- Portfolio/creative sites where 3D is a differentiator
- Web-based tools that replace desktop 3D applications for simple tasks
It’s overkill for:
- Simple animations (CSS and Framer Motion are easier)
- Icon or illustration animation (Lottie)
- Most UI work where 2D is sufficient
The learning curve is real. Getting the lighting right, understanding the coordinate system, and managing performance on mobile all take time. Budget 2-3 days to get comfortable before committing to a client project. Start with drei’s examples, which cover 80% of the common patterns with working code.
Sponsored
More from this category
More from Web Development
SolidJS in 2026: Fine-Grained Reactivity Without the Virtual DOM
TanStack Router in 2026: Type-Safe Routing That Rewires How You Think About Navigation
Web Images in 2026: AVIF, WebP, and the LCP Work Nobody Does Until It's a Problem
Sponsored
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored