SKILL.md
Build a 3D Interactive Website
One focused, SSR-safe, performant hero scene beats a sprawling broken one. Build the hero first, guard every SSR and loading edge, and verify on a phone-sized viewport before calling it done.
Two modes
- Build mode (default). Implement in the current project with R3F plus drei.
- Prompt mode. Only when the user explicitly asks for a prompt to paste into another builder. Emit a single self-contained master prompt covering scene, interactions, SSR guards, and the perf checklist below.
If the user asks for code targeting a framework this project does not run, refuse and offer prompt mode. Foreign framework components will not run here.
Build workflow
- Clarify scope sparingly, two questions max: theme/tone, primary 3D object, key interactions (hover/scroll/click), single page vs multi-section. Ask for a reference image if none was given: screenshots raise fidelity dramatically.
- Confirm deps. Check
package.jsonfor@react-three/fiber. If missing, addthree,@react-three/fiber,@react-three/drei, and dev-dep@types/threewith the project's package manager. - Wire SSR-safely. Three.js touches
window; server rendering crashes without a guard. Mount the Canvas behind a client-only wrapper (rendernulluntil after hydration), and exclude three packages from SSR externalization in the bundler config. Required, not optional. - Build the hero scene first. Pick the closest archetype (floating hero object, tilt cards, scroll-driven explorer, premium showcase) and get it rendering before expanding to other sections.
- Perf pass before declaring done:
dpr={[1, 2]}cap,frameloop="demand"for static scenes, lazy-load GLTF,<Suspense>fallback on every async asset, touch controls on mobile,prefers-reduced-motionfallback. - Verify per the section below.
Guardrails
- Never render
<Canvas>at module scope or in a server-rendered route body without the client-only wrapper. The reconciler callswindow/documentat mount. - Never load
.gltf,.hdr, or large textures without<Suspense fallback={...}>. The route flashes blank otherwise. - Never ship without a
prefers-reduced-motionbranch that stills the animation and shows a static frame. - Cap DPR at 2. Uncapped DPR tanks retina and mobile FPS.
- Camera FOV 35-50 for product and hero shots; 60-75 only for immersive explorers.
Good vs bad
Bad: import the Canvas component at the top of a server-rendered route, ship, and discover "window is not defined" only in production builds.
Good: a ClientOnly wrapper that returns null until mounted, Canvas inside it with dpr={[1, 2]}, GLTF behind Suspense with a branded loading fallback.
Verification
Run the dev server and load the route. Expect no SSR crash, no console errors, and no "Hooks can only be used within the Canvas" warnings. If the SSR crash appears, the Canvas is mounting during server render: add the client-only wrapper and bundler SSR exclusions.
Then open the route at a 375px viewport and take a screenshot. Expect the scene rendered, interactive via touch, and legible. If the scene is blank on mobile only, check DPR cap and texture sizes first.
Completion checklist
- [ ] Project build succeeds
- [ ] Route loads with no
window is not definedSSR crash - [ ] Console clean, no R3F hook warnings
- [ ] 375px viewport renders and responds to touch
- [ ] Reduced-motion users see a still frame, not a frozen loading state
- [ ] All async assets behind Suspense with a real fallback
- [ ] DPR capped, static scenes on
frameloop="demand"
Any box unchecked: not done. Fix or say so.
Footguns
- Canvas at module scope. Crashes SSR at import time. Fix: client-only wrapper plus SSR exclusion for three packages.
- GLTF without Suspense. Blank flash, sometimes a permanent blank on slow networks. Fix:
<Suspense fallback>around every loader. - Uncapped DPR. 3x pixel ratio on phones quarters the frame rate. Fix:
dpr={[1, 2]}. - Reduced-motion ignored. Accessibility failure and a common audit rejection. Fix: media-query branch that freezes the animation loop with a composed static frame.