The Modern Three D on the Web: Three.js and Beyond
Three-dimensional rendering on the web uses WebGL (or the newer WebGPU) to draw GPU-accelerated 3D graphics in a browser canvas element. Three.js is the dominant JavaScript library for WebGL, providing a scene graph, camera system, lighting model, and material system that abstracts the raw WebGL API. React Three Fiber (R3F) integrates Three.js with React's component model, allowing 3D scenes to be composed with JSX and managed with React's state and lifecycle. The decision to add 3D to a web product is a significant performance and complexity tradeoff that is appropriate for specific use cases and inappropriate for most.
Written by Yashveer Singh, founder of Yashveer Labs.
What you actually need to know
- 3D on the web has a real performance cost, especially on mobile. A scene that runs at 60fps on desktop may run at 15fps on a mid-range Android phone.
- CSS animations and 2D canvas cover most visual effects that initially seem to call for 3D. Use WebGL 3D only when the third dimension provides meaningful user value.
- React Three Fiber makes Three.js accessible to React developers but adds reconciler overhead. For performance-critical scenes, raw Three.js gives more control.
- Device performance detection and graceful fallback (static image when the device is too slow) are required for production 3D.
- WebGPU is the future but WebGL is the present. Use WebGL for production; evaluate WebGPU for Chrome-targeted new projects.
| Use Case | Right Tool | 3D Appropriate? | Performance Cost |
|---|---|---|---|
| Product visualization (e-commerce) | Three.js / R3F | Yes | Medium -- user-initiated |
| Background animation | CSS / Lottie | No | 3D overkill |
| Data visualization (3D scatter) | Three.js / D3 | Sometimes | High |
| Hero section animation | CSS / WebGL 2D | Rarely | CSS has zero cost |
| Interactive marketing demo | R3F + Drei | Yes | Medium |
| Game / simulation | Three.js (raw) | Yes | High |
The core argument
The majority of requests for 3D on the web are not actually for 3D -- they are for "something more impressive than what we have now." The team that wants a 3D floating background animation usually wants a more dynamic homepage, not specifically a three-dimensional scene. The team that wants a 3D product viewer sometimes wants a better product image experience. The question to ask before starting a 3D project is not "how do we build this in Three.js?" but "what is the user outcome we are trying to improve, and is 3D the right mechanism?"
In the specific cases where 3D is the right mechanism, Three.js and React Three Fiber are mature, capable tools. A product visualizer for an e-commerce site that lets users rotate a furniture item is a clear case: the third dimension is essential to the use case, the user interaction is intentional (not background noise), and the performance budget is reasonable because the user is already engaged with the product page. A 3D hero section that plays an animation the user did not request, on a page where the primary goal is to convert visitors, is a case where the performance cost -- visible page weight, animation jank on mobile, delayed interactivity -- almost certainly costs more than the visual differentiation gains.
This is not an argument against 3D on the web. It is an argument for honesty about when 3D serves the product and when it serves the designer's portfolio.
Three.js fundamentals
Three.js requires three core elements to render anything: a scene, a camera, and a renderer.
```javascript import * as THREE from 'three';
// Renderer: handles GPU drawing const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // cap at 2x document.body.appendChild(renderer.domElement);
// Scene: container for all 3D objects const scene = new THREE.Scene();
// Camera: defines the viewpoint const camera = new THREE.PerspectiveCamera( 75, // field of view window.innerWidth / window.innerHeight, // aspect ratio 0.1, // near plane 100 // far plane ); camera.position.z = 5;
// Geometry + Material = Mesh (an actual 3D object) const geometry = new THREE.BoxGeometry(1, 1, 1); const material = new THREE.MeshStandardMaterial({ color: 0x0070f3 }); const cube = new THREE.Mesh(geometry, material); scene.add(cube);
// Lighting (required for MeshStandardMaterial to be visible) const light = new THREE.DirectionalLight(0xffffff, 1); light.position.set(1, 2, 3); scene.add(light); scene.add(new THREE.AmbientLight(0xffffff, 0.5));
// Render loop function animate() { requestAnimationFrame(animate); cube.rotation.x += 0.01; cube.rotation.y += 0.01; renderer.render(scene, camera); } animate(); ```
The render loop (requestAnimationFrame) runs 60 times per second. Every computation in the loop runs 60 times per second. Keep the loop lean: no DOM queries, no garbage-creating allocations, no synchronous API calls.
React Three Fiber
React Three Fiber wraps Three.js in a React component model. The same cube in R3F:
```tsx import { Canvas, useFrame } from '@react-three/fiber'; import { useRef } from 'react'; import type { Mesh } from 'three';
function RotatingCube() { const meshRef = useRef<Mesh>(null);
useFrame((state, delta) => { if (meshRef.current) { meshRef.current.rotation.x += delta; meshRef.current.rotation.y += delta * 0.5; } });
return ( <mesh ref={meshRef}> <boxGeometry args={[1, 1, 1]} /> <meshStandardMaterial color="#0070f3" /> </mesh> ); }
export function Scene() { return ( <Canvas camera={{ position: [0, 0, 5] }}> <ambientLight intensity={0.5} /> <directionalLight position={[1, 2, 3]} intensity={1} /> <RotatingCube /> </Canvas> ); } ```
The Canvas component initializes the Three.js renderer. JSX elements like <mesh>, <boxGeometry>, and <meshStandardMaterial> map to Three.js constructors. useFrame provides access to the render loop without managing requestAnimationFrame manually.
The Drei library (@react-three/drei) adds helper components: <OrbitControls> for user camera interaction, <Environment> for HDR lighting, <Text> for 3D text, and many more. For most R3F projects, Drei reduces the amount of Three.js imperative code significantly.
Performance optimization for production 3D
Pixel ratio cap. renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) prevents rendering at 3x or 4x pixel density on high-DPI devices. The visual difference between 2x and 3x is minimal; the GPU cost is significant.
Geometry and material reuse. Create geometries and materials once and reuse them across multiple meshes (THREE.InstancedMesh for many identical objects). Creating new geometry or material objects inside the render loop creates garbage that the JavaScript GC must collect -- causing frame drops.
Level of detail. THREE.LOD reduces triangle count for objects far from the camera. A complex 10,000-polygon object near the camera can be a 500-polygon approximation at a distance, without the user noticing.
Device capability detection. Detect GPU capability before rendering and choose a quality preset:
```javascript const gl = canvas.getContext('webgl2'); const debugInfo = gl?.getExtension('WEBGL_debug_renderer_info'); const renderer = debugInfo ? gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) : 'unknown';
// Fall back to static image for very low-end devices const isLowEnd = renderer.toLowerCase().includes('mali-4') || renderer.toLowerCase().includes('adreno 3'); ```
Common mistakes teams make with web 3D
- Not measuring performance on mobile before shipping. The GPU that handles a 60fps Three.js scene on desktop may be 4-8x faster than a mid-range Android GPU. Test on real hardware, not just Chrome's DevTools device simulation.
- Running physics calculations or pathfinding in the Three.js render loop. The render loop runs 60 times per second; heavy computation here drops frames. Move expensive calculations to a Web Worker.
- Loading large GLTF model files without optimization. GLTF files from 3D modeling tools are often 10-50MB. Use Draco compression (Three.js has a Draco loader) and compress textures before shipping. A product model should be under 2MB for reasonable mobile load times.
- Not implementing a canvas fallback. When WebGL is not available (very old browsers, some embedded environments), the canvas element is blank. Provide a fallback image or message.
- Treating a 3D decoration as "free." Every Three.js scene has initialization cost (shaders must compile on first load) that blocks the main thread for 100-500ms. This delay is visible as a pause in page interactivity, which affects Core Web Vitals and user perception of page speed.
Where to start: a 3-step 3D web project decision
Step 1: Define the user outcome the 3D feature is intended to improve. If the outcome is not measurable (not "reduce product uncertainty for furniture buyers by 20 percent" but just "looks more impressive"), the project is a style decision, not a product decision. Style decisions should have commensurately smaller performance budgets.
Step 2: Test the performance impact on a mid-range Android device before committing to the implementation. A quick prototype that renders the intended scene complexity in Three.js, tested on an actual Android device, surfaces the performance limitations before the implementation is complete.
Step 3: Implement the graceful fallback before the 3D feature. The static image or simpler alternative that appears when the device is too slow should be built before the 3D scene. This forces the clarity about what the minimum viable experience is, and ensures the fallback exists before it is needed.
The Dimension That Earns Its Cost
Yashveer Singh. Founder of Yashveer Labs. I have been asked to add 3D to three client projects. In two cases, a careful conversation about the intended user outcome produced a different solution: one needed a product video (easier to produce, better mobile performance, equivalent user information), and one needed a better photograph (the 3D model would have been 15MB and rendered at 10fps on the target user demographic's devices). In the third case -- a real estate platform where buyers needed to explore floor plans spatially -- 3D was the right answer, and the implementation used Three.js with a performance-tiered renderer that showed a 2D floor plan SVG on devices below the WebGL performance threshold. The 3D that earns its cost is the 3D that provides something the alternative cannot -- and the alternatives are usually underconsidered.
Related reading
Frequently asked
Why this is the work I do
The work in this article is not theoretical for me. It is what I shipped last quarter, last month, and this week. Yashveer Singh, founder of Yashveer Labs. I do not write about things I have not done. I do not pretend to expertise I do not have. If the topic here is the topic you are dealing with, I am the person who has dealt with it. Multiple times. Recently.
Posts that line up with this one.
- Web App and Frontend Development
The Frontend Build System: Why It Matters More Than Founders Think
How your JavaScript build system affects developer velocity, deployment reliability, and application performance -- and what founders need to know about it.
- Web App and Frontend Development
The Mobile Web Experience That Converts
Most web traffic is mobile. The specific technical decisions that determine whether mobile users convert or leave -- from LCP to tap target size.
- Web App and Frontend Development
Loading States, Skeletons, and Optimistic UI
How you handle loading states is one of the most visible indicators of product quality. Here is the decision framework for when to use spinners, skeletons, and optimistic updates, and the common mistakes that make apps feel slow.
- Web App and Frontend Development
Modal Patterns That Do Not Trap Users
Modals are overused, frequently misimplemented, and a common source of user frustration. Here is how to design and build modals that provide the right information at the right time without trapping users or creating accessibility failures.