Adding 3D to a React Site with three.js and React Three Fiber | Roman Humagain
A practical guide to React Three Fiber: the declarative model, lighting and materials, performance budgets, texture CORS, accessibility and graceful fallbacks.
Adding 3D to a website used to mean maintaining a parallel imperative codebase — a scene graph, a render loop and manual cleanup, all living awkwardly beside your components. React Three Fiber removes that split. You describe a scene the same way you describe a form. The API is easy. The parts that decide whether the result is good are not, so this covers both. The declarative model R3F maps every three.js class to a JSX element. THREE.Mesh becomes <mesh>, THREE.BoxGeometry becomes <boxGeometry>. Constructor arguments go through args, and properties are just props. import { Canvas } from "@react-three/fiber"; <Canvas camera={{ position: [0, 0, 6], fov: 45 }}> <ambientLight intensity={0.6} /> <directionalLight position={[4, 6, 5]} /> <mesh rotation={[0.4, 0.2, 0]}> <boxGeometry args={[1, 1, 1]} /> <meshStandardMaterial color="#111" /> </mesh> </Canvas> There is no separate render loop to manage and no manual disposal for anything declared this way — unmounting cleans up. Animation lives in useFrame, not state The instinct is to animate with useState. Do not: it triggers a React render on every frame. useFrame runs inside the render loop and should mutate refs directly. const ref = useRef(); useFrame((state, delta) => { // clamp delta — a backgrounded tab resumes with a huge value ref.current.rotation.y += Math.min(delta, 0.1) * 0.5; }); Two details worth keeping. Multiply by delta so motion is frame-rate independent. And clamp it, or a tab that was in the background for a minute resumes with a single enormous step and everything jumps. Materials need something to reflect A common first disappointment: you set metalness={1} expecting chrome and get a black silhouette. That is correct behaviour — a mirror in an empty room reflects nothing. Metallic materials need an environment map. Drei's <Environment /> is the quick answer, though the presets fetch an HDRI from a CDN. If you would rather not depend on that, you can generate one locally: draw a gradient with a couple of bright rectangles onto a canvas, run it through PMREMGenerator, and assign the result to scene.environment. It costs nothing at runtime and works offline. Performance is a budget, not an afterthought A full-screen WebGL canvas is the most expensive thing on a page. Four things matter more than everything else combined. Pixel ratio. Rendering at a phone's native 3x device pixel ratio means nine times the fragment work of 1x. Clamp it: <Canvas dpr={[1, 1.5]} /> Stop rendering what nobody is looking at. If a page has several canvases, the offscreen ones should not be running. Gate the loop on visibility with an IntersectionObserver: <Canvas frameloop={inView ? "always" : "never"} /> This is the single biggest win on a multi-section page — idle GPU work drains battery and steals frames from scrolling. Watch what you allocate in useFrame. Creating a vector inside the loop allocates sixty objects a second for the garbage collector. Hoist scratch objects outside it. Instance repeated geometry. A thousand separate meshes is a thousand draw calls; <instancedMesh> makes it one. HTML inside a 3D scene Drei's <Html> projects real DOM into the scene, which is usually the right way to put text in 3D — it stays crisp at any zoom, it is selectable, and screen readers can reach it. Rasterising text into a texture gives up all three. The caveat is that DOM overlays do not participate in the depth buffer. They paint above the canvas and are ordered among themselves by z-index, not by distance from the camera. If elements need to occlude each other correctly, either sort them yourself from their projected depth, or use real geometry instead. Textures and CORS This one costs people an afternoon. An image the browser will happily display in an <img> may be refused as a WebGL texture, because uploading pixels to the GPU requires the host to send an Access-Control-Allow-Origin header. Without it the load fails and you get a blank surface. Set the crossOrigin flag on the loader, and have a plan for failure: const loader = new THREE.TextureLoader(); loader.setCrossOrigin("anonymous"); If you do not control the image host, the reliable fix is to re-serve the bytes from your own origin. A small endpoint that takes a record id — never a URL from the request — looks up the stored image, fetches it server-side and streams it back with permissive CORS headers. Keying on an id rather than a URL matters: an endpoint that fetches whatever URL it is handed is a server-side request forgery hole pointed at your internal network. Not everyone gets WebGL Some browsers block it, some drivers are blacklisted, and some users have hardware acceleration off. Detect it once and design a real fallback: const canvas = document.createElement("canvas"); const gl = canvas.getContext("webgl2") || canvas.getContext("webgl"); The fallback should be the content, not an apology. A 3D skills orb degrades perfectly well to a list of skills. Respect reduced motion Continuous motion is a genuine accessibility problem for people with vestibular disorders. prefers-reduced-motion is not a suggestion: const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; Honour it by stopping rotation and settling objects into their resting position — a still, well-composed scene, not a missing one. The judgement call 3D on the web is at its best when it carries meaning: showing a product from every angle, making a spatial relationship legible, giving a portfolio a memorable moment. It is at its worst as decoration that costs a second of load time and a third of the battery. The tooling has become genuinely pleasant. The discipline — budget the frames, plan the fallback, respect the user's settings — is still the part that decides whether it was worth doing.