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/dreiimport { 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
- Justify the 3D. Name the one moment it serves. Rule out a 2D alternative first.
- Mount client-only.
next/dynamic({ ssr: false })orReact.lazyaround the Canvas. Never SSR WebGL. - 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.
- Load models correctly. Compressed
.glb(Draco or Meshopt), a few MB max.useGLTFinside<Suspense fallback={...}>, plususeGLTF.preload(url). - Animate in `useFrame`, mutate refs.
ref.current.rotation.y += delta. Never callsetStateper frame. For scroll:ScrollControls+useScroll, drive the camera fromscroll.offset. - Apply the perf budget. Cap
dpr, addAdaptiveDprorPerformanceMonitor, instance repeats,BakeShadowsfor static lights, dispose anything you created manually. - Add the fallback layer. The page must convey its message with the canvas removed: static image or text behind it. Respect
prefers-reduced-motionby freezing auto-rotation and idle float. Decorative canvases getaria-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 withssr: 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
.glbwith 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
- [ ]
dprcapped; instancing/adaptive perf where relevant - [ ] Models are compressed
.glbunder Suspense with preload - [ ] No
setStateinuseFrame; refs mutated withdelta - [ ] 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.