Skip to content

React Three Fiber Fundamentals

A Three.js guide for coding agents. Also covers r3f, react three fiber, fiber, declarative three.js, jsx scene graph, Canvas component, and 26 more.

Show all 32 aliases

r3f, react three fiber, fiber, declarative three.js, jsx scene graph, Canvas component, canvas defaults, useFrame, useThree, extend, attach attribute, args attribute, how do I animate in r3f, how do I access the camera in r3f, how do I add orbit controls in r3f, cube rotates faster on some devices, frame rate independent animation, transform gizmo, TransformControls, PivotControls, Html label on mesh, drei Text, SDF font text, Float helper, MeshReflectorMaterial, leva debug ui, useControls, r3f-perf, monitor draw calls r3f, tone mapping r3f, pixel ratio dpr, custom buffer geometry r3f

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 the THREE namespace, 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

  • attach assigns a child to a property of its parent instead of calling add(). <boxGeometry attach="geometry" /> sets mesh.geometry
  • You rarely write it because R3F infers it from the tag name: components ending in Geometry attach to geometry, components ending in Material attach to material
  • You do write it for buffer attributes: attach="attributes-position" walks the path and sets geometry.attributes.position
<bufferGeometry>
  <bufferAttribute
    attach="attributes-position"
    count={verticesCount}
    itemSize={3}
    array={positions}
  />
</bufferGeometry>

args: constructor parameters

  • args is an array passed to the class constructor, in the same order as the Three.js docs: <sphereGeometry args={[1.5, 32, 32]} /> is new SphereGeometry(1.5, 32, 32)
  • Changing args rebuilds the whole object. Never animate geometry args; each change reconstructs the geometry
  • Materials take an options object as their single constructor argument, so skip args and set properties as individual props instead: <meshBasicMaterial color="red" wireframe /> beats args={[{ color: 'red' }]}

Prop shortcuts

  • Vector props accept arrays: position={[1, 2, 3]} calls position.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" calls material.color.set('mediumpurple')
  • A bare boolean prop is true: wireframe means wireframe={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, conventionally Experience. 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 null renders 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].

  • camera takes an object of camera properties: fov, near, far, zoom, position
  • orthographic swaps in an OrthographicCamera. Control framing with zoom, not top/right/bottom/left, because R3F manages those to keep the aspect ratio correct. fov is meaningless here
  • gl takes WebGLRenderer options: antialias, toneMapping, outputColorSpace, etc. Import THREE for the enum values
  • flat disables tone mapping (NoToneMapping); linear sets LinearSRGBColorSpace output
  • dpr={[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.01 runs faster on high refresh screens; rotation.y += delta is frame rate independent
  • state is the live R3F state: state.camera, state.scene, state.gl, state.clock. Use state.clock.elapsedTime for 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, with gl.domElement for the canvas), scene, clock, sizes
  • Use useThree for setup-time access, useFrame's state argument for per-frame access
const { camera, gl } = useThree()

Refs, useMemo, useEffect with Three objects

  • useRef + the ref prop is how you grab the underlying Three.js instance for imperative work (animation, method calls). ref.current is the real Mesh, Group, or BufferGeometry
  • ref.current is undefined during the first render. Put imperative calls in useEffect, which runs after the first render, instead of in the component body
  • Expensive data (vertex arrays, curves, lookup tables) goes in useMemo so 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 normal attribute renders wrong under lights; call computeVertexNormals() or supply normals
  • side={THREE.DoubleSide} needs the THREE import (or import { DoubleSide } from 'three'); enum values are not stringly typed

extend: classes outside the core

  • Classes not in the THREE namespace (examples/addons like OrbitControls) 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 from useThree
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 whole extend dance and enables damping by default, with per-frame updates handled for you
  • makeDefault registers 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
  • mode="translate" | "rotate" | "scale"
  • Without makeDefault on 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. With fixed, scale is in screen pixels and stays constant with camera distance (use something like scale={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 > div
  • center moves the pivot to the element's center; distanceFactor={8} scales it with camera distance to fake perspective
  • occlude={[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
  • font takes a path served from public/; prefer woff (troika supports woff, ttf, otf). Default is Roboto from the Google Fonts CDN
  • Props: fontSize, color, maxWidth (wrapping), textAlign, plus the usual position / 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 speed and floatIntensity
<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]} with mixBlur={1} for blurry reflections, mirror={0.5} for reflection strength, color to 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 useControls in 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). invertY fixes 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 use transparent + opacity
    • Boolean: visible: true renders a checkbox
    • Interval: { min: 0, max: 10, value: [4, 5] } gives a two-cursor range
    • Button: clickMe: button(() => doThing()) with button imported from leva
    • Select: choice: { options: ['a', 'b', 'c'] }
  • A string first argument to useControls('sphere', {...}) puts the tweaks in a folder; call useControls again with another name for another folder (nested folders via the folder import)
  • 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 /> from r3f-perf is the R3F-specific monitor: FPS, draw calls, triangles, memory, GPU frame time. Far richer than Stats.js
  • position="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 useFrame or useThree in 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.current in the component body on first render, when it is still undefined; use useEffect
  • Rebuilding vertex arrays on every render instead of caching them with useMemo
  • Forgetting makeDefault on 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 as meshStandardMaterial
  • 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/left instead of zoom
  • Using TextGeometry for UI-sized or long text when drei <Text> (SDF) is sharper and cheaper

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-three-js-guide({ topic: "react-three-fiber-fundamentals" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems