Skip to main content
AC
3D & Games5.2 KBMIT licensed

react-three-webgl

Rewritten from patterns in pmndrs react-three-fiber and drei examples (MIT)

React Three Fiber and drei implementation reference: scenes, cameras, lighting, GLTF loading, scroll-linked 3D, post-processing, performance budgets, fallbacks. Use when writing or reviewing R3F/three.js code for a hero object, configurator, 3D data viz, or ambient background. Not for the end-to-end 3D marketing site workflow: use build-3d-website. Not for playable games with mechanics or saves: use build-3d-game.

  • react
  • three
  • webgl

SKILL.md

React Three / WebGL

R3F renders a Three.js scene from a React component tree; drei supplies the helpers that kill the boilerplate. 3D is the heaviest thing on a page, so the rule is one purposeful 3D moment, lazy-loaded, with a real fallback. If CSS, SVG, or a 2D animation delivers the same moment, use that instead.

Minimal scene

npm install three @react-three/fiber @react-three/drei
import { Canvas } from "@react-three/fiber";
import { OrbitControls, Environment, Float } from "@react-three/drei";

export function Hero3D() {
  return (
    <Canvas camera={{ position: [0, 0, 5], fov: 45 }} dpr={[1, 2]}>
      <ambientLight intensity={0.4} />
      <directionalLight position={[5, 5, 5]} intensity={1} />
      <Float speed={1.5} rotationIntensity={0.6} floatIntensity={0.8}>
        <mesh>
          <icosahedronGeometry args={[1, 0]} />
          <meshStandardMaterial color="#6366f1" roughness={0.3} metalness={0.6} />
        </mesh>
      </Float>
      <Environment preset="city" />
      <OrbitControls enablePan={false} enableZoom={false} />
    </Canvas>
  );
}

dpr={[1, 2]} caps pixel ratio so retina phones do not render 4x the pixels. Inside <Canvas> is Three.js space, outside is normal DOM.

Workflow

  1. Justify the 3D. Name the one moment it serves. Rule out a 2D alternative first.
  2. Mount client-only. next/dynamic({ ssr: false }) or React.lazy around the Canvas. Never SSR WebGL.
  3. Build the scene with drei helpers instead of raw Three.js setup. Read references/drei-catalog.md when picking helpers for controls, loading, motion, scroll, text, or perf.
  4. Load models correctly. Compressed .glb (Draco or Meshopt), a few MB max. useGLTF inside <Suspense fallback={...}>, plus useGLTF.preload(url).
  5. Animate in `useFrame`, mutate refs. ref.current.rotation.y += delta. Never call setState per frame. For scroll: ScrollControls + useScroll, drive the camera from scroll.offset.
  6. Apply the perf budget. Cap dpr, add AdaptiveDpr or PerformanceMonitor, instance repeats, BakeShadows for static lights, dispose anything you created manually.
  7. Add the fallback layer. The page must convey its message with the canvas removed: static image or text behind it. Respect prefers-reduced-motion by freezing auto-rotation and idle float. Decorative canvases get aria-hidden; interactive controls get labeled DOM affordances. Essential content never lives only inside WebGL.

Good vs Bad

Bad: useFrame(() => setRotation(r => r + 0.01)) driving a prop into the mesh. React re-renders 60 times a second, jank on any real page.

Good: useFrame((_, delta) => { meshRef.current.rotation.y += delta; }). Direct mutation, zero re-renders, frame-rate independent because it uses delta.

Verification

Run the site in Chrome, open DevTools Performance, record 5 seconds of the 3D section with 4x CPU throttling. Expect a steady frame rate near 60fps and no long tasks from React renders. If frames drop: check for setState in useFrame, uncapped dpr, or un-instanced repeated meshes.

Then disable JavaScript (or block WebGL) and reload. Expect the page to still communicate its message via the fallback. If the section is blank, the fallback is missing: add it before shipping.

Check payload: ls -lh public/models/. Expect hero models in the low single-digit MB. Bigger: recompress with Draco/Meshopt or reduce the mesh.

Footguns

  • SSR-rendered Canvas. Crashes or hydration mismatch, window is not defined. Fix: dynamic import with ssr: false.
  • setState inside useFrame. Re-render storm. Fix: mutate refs, keep React state for discrete UI events only.
  • Uncompressed GLTF. Tens of MB blocking interaction. Fix: export .glb with Draco compression, preload, wrap in Suspense.
  • Multiple SPF-style stacking of post effects. Each postprocessing pass costs a full-screen render. Fix: one effect (Bloom or DoF), not a stack.
  • Leaked resources on unmount. Manually created geometries/materials/textures persist. Fix: R3F auto-disposes declarative objects; call .dispose() on anything created imperatively.

Completion checklist

  • [ ] One purposeful 3D moment; 2D alternative was ruled out
  • [ ] Canvas is client-only and lazy-mounted
  • [ ] dpr capped; instancing/adaptive perf where relevant
  • [ ] Models are compressed .glb under Suspense with preload
  • [ ] No setState in useFrame; refs mutated with delta
  • [ ] DOM fallback carries the message; reduced-motion respected; decorative canvas aria-hidden
  • [ ] Profiled near 60fps under CPU throttling

Any box unchecked: not done. Fix or say so.

Reference files

More in 3D & Games

All skills