Adding Interactive 3D to Web Apps with React Three Fiber and Drei
Abhishek Sharma
Software Developer
Adding Interactive 3D to Web Apps with React Three Fiber and Drei
Three.js is one of the most powerful rendering libraries on the web, but its imperative API clashes with React's declarative model. You create scenes with new THREE.Scene(), manually add objects, and manage an animation loop yourself. React Three Fiber (R3F) wraps all of that in a React component tree, so you can treat 3D objects like any other component -- with props, state, hooks, and lifecycle management. I have used this combination in three production applications: a product model viewer for Cygnet Design (an engineering firm), CAD file visualization in ForgeCadNeo, and decorative 3D scenes in my portfolio at NeoCodeHub. Here is everything I have learned about making 3D work in production web apps.
The Bundle Size Problem (And How to Solve It)
Before writing a single line of 3D code, you need to understand the cost. Three.js is approximately 600KB minified. Adding React Three Fiber and Drei adds another 100KB. That is 700KB of JavaScript that your users download before they see a single polygon. For a portfolio site or marketing page, this is often unacceptable if loaded eagerly.
The solution is aggressive code splitting. Never import the Canvas component at the top level of a page.
// BAD -- loads Three.js on every page visit\
import { Canvas } from '@react-three/fiber';\
import { OrbitControls } from '@react-three/drei';\
export default function ProductPage() {\
return <Canvas>...</Canvas>;\
}\
// GOOD -- loads Three.js only when the 3D section enters the viewport\
import dynamic from 'next/dynamic';\
import { Suspense } from 'react';\
const ProductViewer = dynamic(\
() => import('@/components/product-viewer'),\
{\
ssr: false, // Three.js cannot run on the server\
loading: () => (\
<div className="w-full h-[500px] bg-muted animate-pulse\
rounded-xl flex items-center justify-center">\
<p className="text-muted-foreground">Loading 3D viewer...</p>\
</div>\
),\
}\
);\
export default function ProductPage() {\
return (\
<section>\
<h2>Interactive Product View</h2>\
<Suspense fallback={null}>\
<ProductViewer modelUrl="/models/assembly.glb" />\
</Suspense>\
</section>\
);\
}With this pattern, the 700KB Three.js bundle is only fetched when the component mounts. On my portfolio, Lighthouse scores improved by 15 points after I moved all 3D content behind dynamic imports. The ssr: false flag is critical -- Three.js accesses window and document during initialization, which crashes server-side rendering.
Complete R3F Setup: Canvas, Camera, and Lighting
Here is the full product viewer component I use in Cygnet Design. This is not a simplified example -- it handles loading states, error boundaries, responsive sizing, and professional-quality lighting.
// components/product-viewer.tsx\
'use client';\
import { Canvas, useThree } from '@react-three/fiber';\
import { OrbitControls, Stage, Html, useProgress, Environment } from '@react-three/drei';\
import { Suspense, useRef, useEffect, useState } from 'react';\
import * as THREE from 'three';\
function Loader() {\
const { progress } = useProgress();\
return (\
<Html center>\
<div className="flex flex-col items-center gap-2">\
<div className="w-32 h-1 bg-muted rounded-full overflow-hidden">\
<div\
className="h-full bg-primary transition-all duration-300"\
style={{ width: `${progress}%` }}\
/>\
</div>\
<p className="text-xs text-muted-foreground font-mono">\
{progress.toFixed(0)}%\
</p>\
</div>\
</Html>\
);\
}\
function ResponsiveCamera() {\
const { viewport } = useThree();\
// Adjust camera distance based on viewport width\
// Mobile devices need the camera pulled back further\
const isMobile = viewport.width < 6;\
return null; // We use this hook for side effects only\
}\
interface ProductViewerProps {\
modelUrl: string;\
autoRotate?: boolean;\
enableZoom?: boolean;\
}\
export default function ProductViewer({\
modelUrl,\
autoRotate = true,\
enableZoom = true,\
}: ProductViewerProps) {\
const containerRef = useRef<HTMLDivElement>(null);\
const [dpr, setDpr] = useState(1.5);\
useEffect(() => {\
// Reduce pixel ratio on mobile for performance\
const isMobile = window.innerWidth < 768;\
setDpr(isMobile ? 1 : Math.min(window.devicePixelRatio, 2));\
}, []);\
return (\
<div ref={containerRef} className="w-full h-[500px] md:h-[600px] rounded-xl overflow-hidden">\
<Canvas\
dpr={dpr}\
camera={{ position: [0, 2, 5], fov: 45, near: 0.1, far: 100 }}\
gl={{\
antialias: true,\
alpha: true,\
powerPreference: 'high-performance',\
preserveDrawingBuffer: false,\
}}\
onCreated={({ gl }) => {\
gl.toneMapping = THREE.ACESFilmicToneMapping;\
gl.toneMappingExposure = 1.2;\
}}\
>\
<Suspense fallback={<Loader />}>\
<Stage\
environment="city"\
intensity={0.5}\
adjustCamera={1.5}\
shadows={{ type: 'contact', opacity: 0.4, blur: 2 }}\
>\
<Model url={modelUrl} />\
</Stage>\
</Suspense>\
<OrbitControls\
autoRotate={autoRotate}\
autoRotateSpeed={1.5}\
enableZoom={enableZoom}\
enablePan={false}\
minPolarAngle={Math.PI / 6}\
maxPolarAngle={Math.PI / 1.8}\
minDistance={2}\
maxDistance={10}\
dampingFactor={0.05}\
enableDamping\
/>\
<ResponsiveCamera />\
</Canvas>\
</div>\
);\
}A few important decisions here. The dpr prop controls the rendering pixel ratio. On a Retina MacBook, devicePixelRatio is 2, which means the canvas renders 4x as many pixels. On mobile, this tanks performance. I cap it at 1 on mobile devices and 2 on desktop. The OrbitControls configuration restricts polar angles to prevent users from flipping the model upside down -- a common usability complaint in product viewers. Panning is disabled because in an embedded viewer it is confusing; users expect to rotate and zoom, not drag the model off-screen.
Loading 3D Models: STL, GLTF, and GLB
Different use cases require different file formats. For ForgeCadNeo, the backend converts STEP files to STL (geometry only, no materials). For Cygnet Design, designers export GLTF/GLB files with embedded materials and textures.
// components/model-loaders.tsx\
import { useLoader } from '@react-three/fiber';\
import { useGLTF } from '@react-three/drei';\
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader';\
import * as THREE from 'three';\
import { useEffect, useRef } from 'react';\
// STL Model -- geometry only, we add our own material\
export function STLModel({ url, color = '#c0a060' }: { url: string; color?: string }) {\
const geometry = useLoader(STLLoader, url);\
const meshRef = useRef<THREE.Mesh>(null);\
useEffect(() => {\
if (meshRef.current) {\
// Center the geometry\
geometry.computeBoundingBox();\
const center = new THREE.Vector3();\
geometry.boundingBox!.getCenter(center);\
geometry.translate(-center.x, -center.y, -center.z);\
// Compute normals for proper lighting\
geometry.computeVertexNormals();\
}\
}, [geometry]);\
return (\
<mesh ref={meshRef} geometry={geometry} castShadow receiveShadow>\
<meshStandardMaterial\
color={color}\
metalness={0.3}\
roughness={0.6}\
envMapIntensity={0.8}\
/>\
</mesh>\
);\
}\
// GLTF/GLB Model -- includes materials, textures, animations\
export function GLTFModel({ url }: { url: string }) {\
const { scene } = useGLTF(url);\
const modelRef = useRef<THREE.Group>(null);\
useEffect(() => {\
// Enable shadows on all meshes\
scene.traverse((child) => {\
if (child instanceof THREE.Mesh) {\
child.castShadow = true;\
child.receiveShadow = true;\
}\
});\
}, [scene]);\
// IMPORTANT: clone the scene so multiple instances do not share state\
return <primitive ref={modelRef} object={scene.clone()} />;\
}\
// Preload models for faster subsequent loads\
useGLTF.preload('/models/default-assembly.glb');The STL loader is straightforward but requires manual centering. CAD software exports STL files with arbitrary origin points -- a model might be centered at (500, 300, 0) in millimeters. Without the centering step, the model appears off-screen. I learned this the hard way in ForgeCadNeo when testers reported "blank screens" that were actually models rendered thousands of units away from the camera.
The GLTF loader through Drei's useGLTF hook is cleaner because GLTF is a scene-graph format -- it includes materials, textures, and a proper coordinate system. The scene.clone() call is essential if you render the same model multiple times. Without cloning, React components share the same Three.js object, and moving one model moves all of them.
Mouse-Reactive Lighting and Interactive Effects
For the NeoCodeHub portfolio, I wanted subtle lighting that responds to cursor movement. This creates an engaging effect without being distracting.
// components/reactive-light.tsx\
import { useRef } from 'react';\
import { useFrame, useThree } from '@react-three/fiber';\
import * as THREE from 'three';\
export function MouseLight({ intensity = 0.8 }: { intensity?: number }) {\
const lightRef = useRef<THREE.PointLight>(null);\
const { pointer, viewport } = useThree();\
useFrame(() => {\
if (lightRef.current) {\
// Map normalized mouse position (-1 to 1) to world coordinates\
const x = (pointer.x * viewport.width) / 2;\
const y = (pointer.y * viewport.height) / 2;\
// Smooth interpolation for natural movement\
lightRef.current.position.x = THREE.MathUtils.lerp(\
lightRef.current.position.x,\
x,\
0.05\
);\
lightRef.current.position.y = THREE.MathUtils.lerp(\
lightRef.current.position.y,\
y,\
0.05\
);\
}\
});\
return (\
<>\
<pointLight\
ref={lightRef}\
position={[0, 0, 4]}\
intensity={intensity}\
color="#c0a060"\
distance={12}\
decay={2}\
/>\
{/* Ambient fill so the scene is never completely dark */}\
<ambientLight intensity={0.15} />\
</>\
);\
}The useFrame hook runs on every animation frame (60 times per second). The lerp (linear interpolation) creates smooth movement instead of the light snapping to the cursor position. The factor of 0.05 means the light moves 5% of the remaining distance each frame, creating a natural "follow" effect with deceleration. This is the single most impactful visual trick I use in portfolio 3D scenes -- it makes static geometry feel alive.
Embedding HTML in 3D Space with Drei's Html Component
One of Drei's most useful features is the Html component, which renders DOM elements attached to 3D world positions. In Cygnet Design's product viewer, we use this to show dimension labels and part names that stick to specific points on the model.
// components/annotated-model.tsx\
import { Html } from '@react-three/drei';\
function AnnotatedModel({ model, annotations }) {\
return (\
<group>\
<GLTFModel url={model} />\
{annotations.map((annotation) => (\
<Html\
key={annotation.id}\
position={annotation.worldPosition}\
distanceFactor={8}\
occlude\
transform\
sprite\
style={{\
transition: 'all 0.2s',\
opacity: 1,\
pointerEvents: 'none',\
}}\
>\
<div className="bg-background/90 backdrop-blur-sm border border-primary/20\
rounded-lg px-3 py-1.5 text-xs font-mono whitespace-nowrap\
shadow-lg">\
<span className="text-primary font-semibold">{annotation.label}</span>\
{annotation.value && (\
<span className="text-muted-foreground ml-2">{annotation.value}</span>\
)}\
</div>\
</Html>\
))}\
</group>\
);\
}\
// Usage\
const annotations = [\
{ id: 'dim-1', label: 'Bore Diameter', value: '25.4mm', worldPosition: [1.2, 0.5, 0] },\
{ id: 'dim-2', label: 'Overall Length', value: '150mm', worldPosition: [0, -0.3, 0.8] },\
{ id: 'mat-1', label: 'Material', value: 'SS 316L', worldPosition: [-0.5, 1.0, 0] },\
];The distanceFactor prop scales the HTML element based on distance from the camera -- labels shrink as you zoom out, maintaining a natural perspective. The occlude prop hides labels when they are behind the model. The sprite prop keeps labels facing the camera regardless of rotation. These three props together create labels that feel like a natural part of the 3D scene rather than overlaid 2D elements.
Performance Optimization: The Draw Call Budget
The single most important performance metric in WebGL is draw calls. Each unique combination of geometry + material + shader = one draw call. Modern desktop GPUs handle 1000+ draw calls at 60fps. Mobile GPUs struggle above 100. Here is how I keep draw calls under budget.
// Using instancedMesh for repeated geometry\
// Example: rendering 500 bolts on an assembly model\
import { useRef, useMemo } from 'react';\
import { useFrame } from '@react-three/fiber';\
import * as THREE from 'three';\
function BoltField({ count = 500, positions }: { count: number; positions: Float32Array }) {\
const meshRef = useRef<THREE.InstancedMesh>(null);\
const tempMatrix = useMemo(() => new THREE.Matrix4(), []);\
useEffect(() => {\
if (!meshRef.current) return;\
for (let i = 0; i < count; i++) {\
const x = positions[i * 3];\
const y = positions[i * 3 + 1];\
const z = positions[i * 3 + 2];\
tempMatrix.setPosition(x, y, z);\
meshRef.current.setMatrixAt(i, tempMatrix);\
}\
meshRef.current.instanceMatrix.needsUpdate = true;\
}, [count, positions, tempMatrix]);\
return (\
<instancedMesh ref={meshRef} args={[undefined, undefined, count]} castShadow>\
<cylinderGeometry args={[0.02, 0.02, 0.08, 6]} />\
<meshStandardMaterial color="#888" metalness={0.8} roughness={0.2} />\
</instancedMesh>\
);\
}\
// 500 bolts = 1 draw call instead of 500In ForgeCadNeo, some CAD models have thousands of fasteners. Without instancing, each bolt would be a separate draw call, making the viewer unusable on mobile. With instancedMesh, we render all identical parts in a single draw call, and the viewport stays at 60fps even on mid-range phones.
Proper Cleanup: Disposing Three.js Resources
Three.js allocates GPU memory for geometries, textures, and materials. React's garbage collector does not know about these allocations. If you unmount a 3D component without disposing its resources, you leak GPU memory. After navigating between pages a few times, the browser tab crashes.
// hooks/use-dispose.ts\
import { useEffect } from 'react';\
import * as THREE from 'three';\
export function useDispose(scene: THREE.Object3D | null) {\
useEffect(() => {\
return () => {\
if (!scene) return;\
scene.traverse((child) => {\
if (child instanceof THREE.Mesh) {\
child.geometry.dispose();\
if (Array.isArray(child.material)) {\
child.material.forEach((mat) => {\
disposeMaterial(mat);\
});\
} else {\
disposeMaterial(child.material);\
}\
}\
});\
};\
}, [scene]);\
}\
function disposeMaterial(material: THREE.Material) {\
material.dispose();\
// Dispose any textures attached to the material\
for (const key of Object.keys(material)) {\
const value = (material as any)[key];\
if (value instanceof THREE.Texture) {\
value.dispose();\
}\
}\
}I call this hook in every component that loads a model. R3F handles some cleanup automatically when the Canvas unmounts, but it does not always catch dynamically loaded models. The explicit dispose pattern is insurance against memory leaks in long-running sessions -- particularly important in ForgeCadNeo where users load dozens of models in a single session.
When 3D Adds Value vs. When It Is a Gimmick
After building 3D features for three different products, I have a clear framework for when Three.js earns its 600KB bundle cost.
3D adds real value when:
- Users need to inspect a physical object from multiple angles (product viewers, CAD visualization)
- Spatial relationships matter (architectural walkthroughs, data visualization in 3D space)
- The 3D interaction directly replaces a more expensive process (ForgeCadNeo replaces desktop CAD software)
3D is a gimmick when:
- A rotating logo on a landing page (use CSS transforms or Lottie instead)
- Background particle effects with no interaction (use CSS or canvas 2D)
- 3D charts that are harder to read than 2D equivalents
For my portfolio, the decorative 3D scene is borderline -- it does not serve a functional purpose. I justify it because the portfolio itself demonstrates frontend capabilities, so a well-optimized 3D scene is evidence of technical skill. For a business landing page, I would not add Three.js just for visual flair.
Accessibility Implications
WebGL canvas elements are invisible to screen readers. A Canvas component renders as an opaque <canvas> tag with no semantic content. If 3D is core to your feature (like a product viewer), you must provide an accessible alternative.
In Cygnet Design, we include a gallery of static 2D renders alongside the 3D viewer. The viewer has an aria-label describing the product, and keyboard users can tab past it to reach the static images. For ForgeCadNeo, the 3D viewer is supplementary -- the primary output is a downloadable STEP file, so screen reader users can still access the core functionality.
The key principle: 3D should enhance an experience, never gate it. If the only way to understand your content is through the 3D viewer, you have an accessibility problem.
Mobile Performance Checklist
Before shipping any R3F component to production, I run through this checklist on a mid-range Android device (I test on a Redmi Note 12, which represents the average user in Indian markets where Errandoo and Cygnet operate).
- Canvas
dprset to 1 on mobile (notdevicePixelRatio) - Total triangle count under 100K for the visible scene
- Draw calls under 80 (check with
renderer.info.render.calls) - No real-time shadows on mobile -- bake them into textures or use contact shadows only
- Textures compressed with Basis Universal or KTX2 format
- Loading placeholder visible while model downloads
- Touch controls tested -- OrbitControls works with touch but needs
enablePan={false}to avoid conflicts with page scrolling
React Three Fiber and Drei together make 3D accessible to React developers without learning the raw Three.js imperative API. But the underlying performance constraints are still GPU constraints. No abstraction layer eliminates the need to think about draw calls, triangle counts, and texture memory. Respect the hardware, and you can ship 3D experiences that delight users on any device.