Mental model
- R3F is a React renderer: JSX describes the scene graph and R3F builds the corresponding Three.js objects, keeps them in sync with props, and renders every frame
- Every Three.js class is available as a camelCase tag with no import:
<mesh>,<boxGeometry>,<meshStandardMaterial>,<group>,<directionalLight>. R3F generates these automatically from theTHREEnamespace, so new Three.js classes work without an R3F update - camelCase tags are auto-generated primitives, PascalCase components are your own or library components.
<mesh>is Three.js,<Experience />is yours - Nesting maps to
add(): children of a<group>or<mesh>are added to the parent Object3D - Sibling order in JSX does not matter, same as native Three.js
- You get a scene, renderer, camera, resize handling, render loop, antialias, tone mapping, and color space for free. Hot reload with auto dispose means most edits apply without a page refresh, but reload if behavior looks stale
- Defaults are sensible:
<boxGeometry />with no args is a unit cube, the camera is already pulled back from the origin
<group>
<mesh position-x={-2}>
<sphereGeometry />
<meshStandardMaterial color="orange" />
</mesh>
<mesh rotation-y={Math.PI * 0.25} position-x={2} scale={1.5}>
<boxGeometry />
<meshStandardMaterial color="mediumpurple" />
</mesh>
</group>
attach: how children bind to parents
attachassigns a child to a property of its parent instead of callingadd().<boxGeometry attach="geometry" />setsmesh.geometry- You rarely write it because R3F infers it from the tag name: components ending in
Geometryattach togeometry, components ending inMaterialattach tomaterial - You do write it for buffer attributes:
attach="attributes-position"walks the path and setsgeometry.attributes.position
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
count={verticesCount}
itemSize={3}
array={positions}
/>
</bufferGeometry>
args: constructor parameters
argsis an array passed to the class constructor, in the same order as the Three.js docs:<sphereGeometry args={[1.5, 32, 32]} />isnew SphereGeometry(1.5, 32, 32)- Changing
argsrebuilds the whole object. Never animate geometryargs; each change reconstructs the geometry - Materials take an options object as their single constructor argument, so skip
argsand set properties as individual props instead:<meshBasicMaterial color="red" wireframe />beatsargs={[{ color: 'red' }]}
Prop shortcuts
- Vector props accept arrays:
position={[1, 2, 3]}callsposition.set(1, 2, 3) scale={1.5}applies one value to all three axes- Dash syntax targets a single nested property:
position-x={2},rotation-y={Math.PI * 0.25}. It works for any pierced property color="mediumpurple"callsmaterial.color.set('mediumpurple')- A bare boolean prop is
true:wireframemeanswireframe={true} - Always pass numbers in braces.
position-x="2"is a string and can cause subtle bugs
Project structure
- Render
<Canvas>at the root and put all scene content in a component inside it, conventionallyExperience. R3F hooks (useFrame,useThree) only work in components rendered inside<Canvas>, never in the component that renders<Canvas>itself - The canvas fills its parent element, so size the parent. The classic reset:
html, body, #root {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
- Split scene pieces into their own components as soon as they get complex or reusable, exactly like DOM components. A component returning
nullrenders nothing
root.render(
<Canvas>
<Experience />
</Canvas>
)
Canvas configuration
Defaults: PerspectiveCamera pulled back from origin, automatic resize, antialias on, ACESFilmicToneMapping, SRGBColorSpace output, transparent background (CSS behind the canvas shows through), dpr clamped to [1, 2].
cameratakes an object of camera properties:fov,near,far,zoom,positionorthographicswaps in an OrthographicCamera. Control framing withzoom, nottop/right/bottom/left, because R3F manages those to keep the aspect ratio correct.fovis meaningless heregltakes WebGLRenderer options:antialias,toneMapping,outputColorSpace, etc. ImportTHREEfor the enum valuesflatdisables tone mapping (NoToneMapping);linearsetsLinearSRGBColorSpaceoutputdpr={[1, 2]}clamps pixel ratio (this is the default);dpr={1}forces a value
<Canvas
dpr={[1, 2]}
gl={{
antialias: true,
toneMapping: THREE.ACESFilmicToneMapping,
outputColorSpace: THREE.SRGBColorSpace,
}}
camera={{ fov: 45, near: 0.1, far: 200, position: [3, 2, 6] }}
>
<Experience />
</Canvas>
Animation with useFrame
useFrame((state, delta) => ...)runs your callback every rendered frame. Only callable inside<Canvas>- Always scale movement by
delta(seconds since last frame).rotation.y += 0.01runs faster on high refresh screens;rotation.y += deltais frame rate independent stateis the live R3F state:state.camera,state.scene,state.gl,state.clock. Usestate.clock.elapsedTimefor absolute-time animation such as orbiting the camera
const cubeRef = useRef()
useFrame((state, delta) => {
cubeRef.current.rotation.y += delta
const angle = state.clock.elapsedTime
state.camera.position.x = Math.sin(angle) * 8
state.camera.position.z = Math.cos(angle) * 8
state.camera.lookAt(0, 0, 0)
})
<mesh ref={cubeRef}> ... </mesh>
useThree
useThree()returns the same state object once, at render time, instead of every frame:camera,gl(the WebGLRenderer, withgl.domElementfor the canvas),scene,clock, sizes- Use
useThreefor setup-time access,useFrame'sstateargument for per-frame access
const { camera, gl } = useThree()
Refs, useMemo, useEffect with Three objects
useRef+ therefprop is how you grab the underlying Three.js instance for imperative work (animation, method calls).ref.currentis the realMesh,Group, orBufferGeometryref.currentisundefinedduring the first render. Put imperative calls inuseEffect, which runs after the first render, instead of in the component body- Expensive data (vertex arrays, curves, lookup tables) goes in
useMemoso re-renders do not rebuild it
export default function CustomObject() {
const geometryRef = useRef()
const verticesCount = 10 * 3
const positions = useMemo(() => {
const positions = new Float32Array(verticesCount * 3)
for (let i = 0; i < verticesCount * 3; i++)
positions[i] = (Math.random() - 0.5) * 3
return positions
}, [])
useEffect(() => {
geometryRef.current.computeVertexNormals()
}, [positions])
return (
<mesh>
<bufferGeometry ref={geometryRef}>
<bufferAttribute
attach="attributes-position"
count={verticesCount}
itemSize={3}
array={positions}
/>
</bufferGeometry>
<meshStandardMaterial color="red" side={THREE.DoubleSide} />
</mesh>
)
}
- A geometry with no
normalattribute renders wrong under lights; callcomputeVertexNormals()or supply normals side={THREE.DoubleSide}needs theTHREEimport (orimport { DoubleSide } from 'three'); enum values are not stringly typed
extend: classes outside the core
- Classes not in the
THREEnamespace (examples/addons likeOrbitControls) have no auto-generated tag.extend({ OrbitControls })registers the class and makes<orbitControls>available in JSX - The object key becomes the tag name, so
extend({ OrbitControls })gives<orbitControls> - Constructor arguments still go through
args; get the camera and canvas fromuseThree
import { extend, useThree } from '@react-three/fiber'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
extend({ OrbitControls })
const { camera, gl } = useThree()
return <orbitControls args={[camera, gl.domElement]} />
- This is the manual path. For common helpers, use drei instead
Drei essentials
drei (@react-three/drei) is the pmndrs helper collection for R3F: controls, text, staging, loaders, materials. Skim its full readme once so you know what exists before reinventing anything.
OrbitControls
<OrbitControls />from drei replaces the wholeextenddance and enables damping by default, with per-frame updates handled for youmakeDefaultregisters it as the default controls so other tools (TransformControls, PivotControls) can pause it while dragging. Add it whenever any other control or gizmo exists
import { OrbitControls } from '@react-three/drei'
<OrbitControls makeDefault />
TransformControls
- Adds a translate/rotate/scale gizmo. Two wiring styles:
- Wrap the mesh. The wrapper is a parent object, so put the position on
<TransformControls position-x={2}>, not on the inner mesh, or the gizmo sits at the origin - Keep them separate and point at the mesh with
object={ref}. Cleaner: the mesh keeps its own transform and the controls can be removed with no side effect
- Wrap the mesh. The wrapper is a parent object, so put the position on
mode="translate" | "rotate" | "scale"- Without
makeDefaulton OrbitControls, dragging the gizmo also orbits the camera
const cube = useRef()
<mesh ref={cube} position-x={2} scale={1.5}> ... </mesh>
<TransformControls object={cube} mode="translate" />
PivotControls
- A prettier gizmo, good enough to expose to end users, wrapped around the target
- It is not a group: position it with
anchor={[x, y, z]}, in units relative to the object's own bounding box ([0, 1, 0]is the top of the object at any scale, and values beyond -1..1 go outside the box) depthTest={false}renders it on top of the scene instead of being hidden inside the mesh- Styling:
lineWidth,axisColors={[c1, c2, c3]},scale. Withfixed, scale is in screen pixels and stays constant with camera distance (use something likescale={100})
<PivotControls anchor={[0, 0, 0]} depthTest={false} scale={100} fixed>
<mesh position-x={-2}> ... </mesh>
</PivotControls>
Html
- Projects a DOM element that tracks a point in 3D. Put it inside any Object3D to stick it to that object, offset with
position wrapperClass="label"gives you a CSS hook; style the inner div via.label > divcentermoves the pivot to the element's center;distanceFactor={8}scales it with camera distance to fake perspectiveocclude={[sphereRef, cubeRef]}hides the label when those meshes pass in front of it
<mesh ref={sphere} position-x={-2}>
<sphereGeometry />
<meshStandardMaterial color="orange" />
<Html position={[1, 1, 0]} wrapperClass="label" center distanceFactor={8} occlude={[sphere, cube]}>
That's a sphere
</Html>
</mesh>
Text
- SDF-based text via troika, superior to TextGeometry: crisp at any size, cheap to generate, supports line breaks, works with most fonts
fonttakes a path served frompublic/; preferwoff(troika supports woff, ttf, otf). Default is Roboto from the Google Fonts CDN- Props:
fontSize,color,maxWidth(wrapping),textAlign, plus the usualposition/rotation/scale - Drop a material child to replace the default one
<Text
font="./bangers-v20-latin-regular.woff"
fontSize={1}
color="salmon"
position-y={2}
maxWidth={2}
textAlign="center"
>
I LOVE R3F
</Text>
Float
- Wraps children in a gentle hover animation, like a balloon. Tune with
speedandfloatIntensity
<Float speed={5} floatIntensity={2}>
<Text> ... </Text>
</Float>
MeshReflectorMaterial
- Drop-in reflective material for planar meshes (floors, mirrors). It does not work well on non-planar geometry
- Key props:
resolution(render target quality, e.g. 512),blur={[1000, 1000]}withmixBlur={1}for blurry reflections,mirror={0.5}for reflection strength,colorto tint
<mesh position-y={-1} rotation-x={-Math.PI * 0.5} scale={10}>
<planeGeometry />
<MeshReflectorMaterial
resolution={512}
blur={[1000, 1000]}
mixBlur={1}
mirror={0.5}
color="greenyellow"
/>
</mesh>
Debugging
StrictMode and DevTools
- Wrap the app in
<StrictMode>in every real project. It surfaces infinite render loops, missing effect dependencies, and deprecated patterns, and is stripped from production builds - React DevTools shows your PascalCase components and their props/state, but not the auto-generated R3F primitives (
<mesh>etc. do not appear in the tree)
root.render(
<StrictMode>
<Canvas> <Experience /> </Canvas>
</StrictMode>
)
Leva
- Leva is the React-native debug UI (the lil-gui equivalent). One hook call creates the panel; call
useControlsin any component, in as many components as you like - Changing a tweak re-renders the component; React and R3F only update what actually changed
- Input types are inferred from the initial value:
- Number:
position: -2, or an object{ value, min, max, step }for a slider - Vector:
{ value: { x: -2, y: 0 }, step: 0.01, joystick: 'invertY' }gives a 2D joystick (a third component works but loses the joystick).invertYfixes the inverted y axis, and dragging past the joystick edge exceeds the range - Color: any color-looking string (
'#ff0000','orange','hsl(...)') or{ r, g, b }. Alpha is ignored by Three.js materials, which usetransparent+opacity - Boolean:
visible: truerenders a checkbox - Interval:
{ min: 0, max: 10, value: [4, 5] }gives a two-cursor range - Button:
clickMe: button(() => doThing())withbuttonimported from leva - Select:
choice: { options: ['a', 'b', 'c'] }
- Number:
- A string first argument to
useControls('sphere', {...})puts the tweaks in a folder; calluseControlsagain with another name for another folder (nested folders via thefolderimport) - Configure the panel by rendering
<Leva collapsed />yourself, outside<Canvas>(it is DOM, not scene content)
import { button, useControls } from 'leva'
const { position, color, visible } = useControls('sphere', {
position: { value: { x: -2, y: 0 }, step: 0.01, joystick: 'invertY' },
color: 'orange',
visible: true,
clickMe: button(() => console.log('ok')),
})
<mesh visible={visible} position={[position.x, position.y, 0]}>
<sphereGeometry />
<meshStandardMaterial color={color} />
</mesh>
r3f-perf
<Perf />fromr3f-perfis the R3F-specific monitor: FPS, draw calls, triangles, memory, GPU frame time. Far richer than Stats.jsposition="top-left"avoids colliding with the Leva panel; gate it behind a Leva boolean so it can be toggled from the debug UI
import { Perf } from 'r3f-perf'
const { perfVisible } = useControls({ perfVisible: true })
{perfVisible && <Perf position="top-left" />}
Common mistakes
- Calling
useFrameoruseThreein the component that renders<Canvas>; hooks only work inside the Canvas tree - Incrementing rotation by a constant instead of
delta, so animation speed depends on the display refresh rate - Animating geometry
args, which rebuilds the geometry every change - Passing numeric props as strings (
position-x="2") instead of braces - Reading
ref.currentin the component body on first render, when it is stillundefined; useuseEffect - Rebuilding vertex arrays on every render instead of caching them with
useMemo - Forgetting
makeDefaulton OrbitControls, so dragging a TransformControls or PivotControls gizmo also moves the camera - Putting the offset on the mesh inside a wrapping
<TransformControls>, which leaves the gizmo at the origin - Expecting lights to affect
meshBasicMaterial; switch to a lit material such asmeshStandardMaterial - Using
<Leva>inside<Canvas>; it is a DOM component and belongs next to the Canvas, not in it - Fighting an orthographic camera with
top/right/bottom/leftinstead ofzoom - Using TextGeometry for UI-sized or long text when drei
<Text>(SDF) is sharper and cheaper