agentleFS
shipiitshipiit/shipit-ui-design/skills/three-d-scene/SKILL.md

three-d-scene

Skill0 starsChanged 6 months agoMITView on GitHub

Author React Three Fiber scenes — camera setup, drei utilities, instancing, suspense boundaries, and mobile-fallback strategy; auto-activates on `/scene` and R3F files.

The file

We split this skill into the rules it gives an agent

7 rules. About 0 are common, 0 are distinctive, and 7 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈0

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 7

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Always wrap content in <Suspense> — drei/textures/gltf throw promises.

    line 54
  2. Very specific:

    Default position: pull back enough that the subject occupies ~60% of frame.

    line 66
  3. Very specific:

    Use useFrame for animation, not React state.

    line 109
  4. Very specific:

    Reduced-motion: if prefers-reduced-motion, render a still-image poster (rendered once at build, or <Canvas frameloop="never"> after first …

    line 129
  5. Very specific:

    Low-end device: use <PerformanceMonitor onDecline={…}> to drop DPR (<AdaptiveDpr pixelated />) and disable post-processing when fps < 40.

    line 131
  6. Very specific:

    Do not put critical content inside <Canvas> — screen readers can't see it.

    line 208
  7. Very specific:

    Always lazy-loaded by the consuming page (dynamic(import …, { ssr: false })).

    line 217

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Three-D Scene

R3F scenes are heavy. Always scoped to a single component, always behind a Suspense boundary, always with a still-image fallback for mobile and reduced-motion users. Adding three, @react-three/fiber, @react-three/drei to deps requires explicit user confirmation (spec 6.8).

Constitution (verbatim — applies to every artifact)

  1. Max 300 lines per file. If a generated component would exceed, split before writing.
  2. No hardcoded design values. Colors, spacing, radii, shadows, durations, easings — all from tokens.
  3. Every interactive element has hover, active, focus-visible, and disabled states.
  4. All motion respects prefers-reduced-motion.
  5. Every image / illustration has alt text or aria-hidden if decorative.
  6. Dark mode is never an afterthought — emitted alongside light from the start.
  7. Stack-respect: never introduce a new framework or styling system; adapt to what's there.

Bundled templates

The four templates in spec 6.8. Pick the closest match before writing custom geometry.

TemplateUse
ambient-particlesDecorative background; instanced points or sprites drifting
product-showcaseSingle hero object, slow auto-orbit, soft env lighting
hero-gradient-meshAnimated noise-driven plane; cheap, looks expensive
scroll-driven-cameraCamera path tied to scroll Y; storytelling sections

Canvas setup

<Canvas
  camera={{ position: [0, 0, 6], fov: 45 }}
  dpr={[1, 2]}                       // cap retina cost
  gl={{ antialias: true, powerPreference: "high-performance" }}
  frameloop="demand"                 // render only on change; switch to "always" only when truly animating every frame
>
  <Suspense fallback={null}>
    <Scene />
  </Suspense>
</Canvas>

Rules:

  • dpr={[1, 2]} — uncapped DPR on 3× phones is the #1 perf foot-gun.
  • frameloop="demand" for static scenes; explicit invalidate() after state change.
  • Always wrap content in <Suspense> — drei/textures/gltf throw promises.

Camera

SceneTypefovNotes
Hero / productPerspective35–45Lower fov = less distortion, more cinematic
Wide environmentPerspective60–75Standard
UI overlaysOrthographic—Pixel-perfect, no perspective drift

Default position: pull back enough that the subject occupies ~60% of frame. Use <PerspectiveCamera makeDefault> from drei when you need to switch cameras at runtime.


drei utilities to prefer

UtilityUse instead of
OrbitControlshand-rolled mouse listeners
Environment (preset="city" | "studio" | "sunset")manual HDRIs
ContactShadowsshadow-map setup for a single ground shadow
Floatcustom sin-wave hover
Instances + Instancemanual instanced-mesh wiring
HtmlDOM-in-3D overlays
useGLTFhand-rolled loader
Bounds + useBoundsmanual fit-to-view
PerformanceMonitormanual fps watcher
AdaptiveDpr, AdaptiveEventsmanual dpr scaling

Environment preset is the cheapest way to get good lighting; reach for it before adding lights manually.


Performance pitfalls

1. Re-creating geometry/material per frame

Define geometry and materials outside useFrame, or memoize.

const geom = useMemo(() => new THREE.SphereGeometry(1, 32, 32), []);

2. Hundreds of meshes vs instanced mesh

Anything > ~50 repeated meshes uses <Instances>:

<Instances limit={1000}>
  <sphereGeometry args={[0.05, 8, 8]} />
  <meshStandardMaterial color={"var(--color-brand)" /* via uniform; see tokens note */} />
  {points.map((p, i) => <Instance key={i} position={p} />)}
</Instances>

(Token integration: pass tokens as JS values resolved at runtime via getComputedStyle, or accept a color prop and let the wrapper component read the token.)

3. Sub-tree state changes triggering re-render

Use useFrame for animation, not React state. State only at scene boundaries.

4. Loading hero models on the main bundle

Lazy-load the scene component:

const Scene = dynamic(() => import("./scene"), { ssr: false, loading: () => <Fallback /> });

5. Shadow-map cost

shadow-mapSize defaults are heavy. Set [1024, 1024] for hero, [512, 512] for ambient. Or skip dynamic shadows entirely and use ContactShadows.

6. Antialiasing on high-DPR mobile

On phones, prefer antialias: false + <Postprocessing>'s SMAA, or just accept aliasing. Test on a real device.


Mobile-fallback strategy

Three layers, applied in order:

  1. Reduced-motion: if prefers-reduced-motion, render a still-image poster (rendered once at build, or <Canvas frameloop="never"> after first paint).
  2. Small viewport: below 768px, swap the <Canvas> for the same poster image.
  3. Low-end device: use <PerformanceMonitor onDecline={…}> to drop DPR (<AdaptiveDpr pixelated />) and disable post-processing when fps < 40.
function HeroScene() {
  const reduced = useReducedMotion();
  const isSmall = useMediaQuery("(max-width: 768px)");
  if (reduced || isSmall) return <HeroPoster />;
  return (
    <Canvas dpr={[1, 2]} frameloop="demand">
      <AdaptiveDpr pixelated />
      <Suspense fallback={<HeroPoster />}>
        <Scene />
      </Suspense>
    </Canvas>
  );
}

The poster is an SVG or static PNG generated alongside the scene — never leave a blank box.


Suspense boundaries

One per resource group. Don't gate the whole canvas on a single boundary if some assets can stream in independently.

<Canvas>
  <Suspense fallback={null}>
    <Environment preset="studio" />
  </Suspense>
  <Suspense fallback={<PlaceholderBox />}>
    <HeroModel />
  </Suspense>
</Canvas>

Outside the canvas, wrap the canvas itself in a Suspense for code-splitting:

<Suspense fallback={<HeroPoster />}>
  <HeroScene />
</Suspense>

Token integration

Three.js takes raw color values; tokens live in CSS. Bridge at the wrapper:

function readToken(name: string) {
  return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
function HeroScene() {
  const brand = useMemo(() => readToken("--color-brand"), []);
  return <Canvas>…<meshStandardMaterial color={brand} />…</Canvas>;
}

Re-read on theme change (subscribe to a theme context or MutationObserver on data-theme). Never paste a hex into the scene file.


Accessibility

A 3D scene is decorative by default. Wrap the <Canvas> in a div with:

<div role="img" aria-label="Animated hero showing floating product cards">
  <Canvas …>…</Canvas>
</div>

Or, if the scene is purely ornamental and the surrounding text covers meaning:

<div aria-hidden="true"><Canvas …>…</Canvas></div>

Do not put critical content inside <Canvas> — screen readers can't see it. Use drei <Html> for any text that must be announced.


File layout

  • Scene component: components/scenes/<Slug>.tsx — ≤ 300 lines.
  • Heavy geometry/shaders split into siblings: <Slug>Mesh.tsx, <Slug>Materials.tsx.
  • Posters: public/scenes/<slug>.poster.svg (or .png if photographic).
  • Always lazy-loaded by the consuming page (dynamic(import …, { ssr: false })).

Anti-patterns

  • <Canvas> without Suspense.
  • Uncapped DPR on retina (dpr omitted).
  • Loading .glb files > 2 MB without compression (.glb → Draco/Meshopt; document the trade).
  • Listening to scroll/mouse with React state — use refs and useFrame.
  • Postprocessing stacks (Bloom + DOF + SSAO) on mobile.
  • Animating CSS variables from inside the canvas — bridge in JS, not via DOM thrash.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.