Add a Spinning 3D Model to Your React Site (R3F)
Video: Add a Spinning 3D Model to Your React Site (R3F) by CelesteAI
Almost every modern product page, portfolio, or landing page has the same little flourish: a 3D object, gently turning, that you can grab to rotate and scroll to zoom. It looks like the kind of thing that needs a 3D engine and a pile of boilerplate. In React, it does not. With React Three Fiber — the React renderer for Three.js — the whole scene is just components, and a spinning model is about ninety lines of TypeScript.
In this build we will spin a low-poly rocket in a React app: a real model built from primitive meshes, lit, casting a shadow, with drag-to-rotate and scroll-to-zoom controls. No model file to download, no useEffect, no manual render loop.
What we are building
- A React + TypeScript project scaffolded with Vite.
- A
Canvasthat sets up the renderer, scene, and camera for us. - A rocket built from primitive meshes (cylinders, a cone, a sphere, boxes) wrapped in a group.
- A steady spin driven by
useFrame. - Lights, a ground plane, and a real shadow.
OrbitControlsfrom drei for drag and zoom.
1. Scaffold the project
This is the same flow as any real React app. Scaffold with Vite, but use the react-ts template, because the entire scene will be written as React components.
npm create vite@latest spinning-rocket-r3f -- --template react-ts
cd spinning-rocket-r3f
npm install
npm install three @react-three/fiber @react-three/drei @types/three
Three libraries do the work:
- three — the actual 3D engine.
- @react-three/fiber — the bridge that renders Three.js through React, so a mesh is a component and the scene is JSX.
- @react-three/drei — a pack of ready-made helpers. It is where
OrbitControlscomes from, so you do not wire pointer events yourself.
2. The HTML shell
Vite scaffolds an index.html. Trim it down to a single full-window root div. Notice there is no canvas tag — fiber creates and manages the canvas for you.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Spinning 3D Model in React</title>
<style>
html, body, #root { margin: 0; height: 100%; }
body { background: #0b1020; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
3. The model: meshes in a group
Open src/main.tsx and clear it. The rocket is one component. Every part is a mesh — a geometry plus a material — and they all live inside one group so the whole model moves, scales, and spins as a single unit.
The geometry and material are child elements of the mesh, written in JSX. cylinderGeometry args={[...]} maps directly to new THREE.CylinderGeometry(...).
import { useRef } from "react";
import { createRoot } from "react-dom/client";
import { Canvas, useFrame } from "@react-three/fiber";
import { OrbitControls } from "@react-three/drei";
import * as THREE from "three";
function Rocket() {
const ref = useRef<THREE.Group>(null);
// useFrame runs once every rendered frame. Turning the group a little each
// frame, scaled by delta, is the entire animation: a steady, smooth spin.
useFrame((_, delta) => {
if (ref.current) ref.current.rotation.y += delta * 0.6;
});
return (
<group ref={ref}>
<mesh castShadow>
<cylinderGeometry args={[0.5, 0.5, 1.7, 48]} />
<meshStandardMaterial color="#f4f6fb" roughness={0.38} metalness={0.15} />
</mesh>
<mesh position={[0, 1.27, 0]} castShadow>
<coneGeometry args={[0.5, 0.85, 48]} />
<meshStandardMaterial color="#e5484d" roughness={0.45} />
</mesh>
<mesh position={[0, 0.32, 0]}>
<cylinderGeometry args={[0.515, 0.515, 0.26, 48]} />
<meshStandardMaterial color="#e5484d" roughness={0.45} />
</mesh>
<mesh position={[0, 0.62, 0.44]} scale={[1, 1, 0.45]}>
<sphereGeometry args={[0.2, 32, 32]} />
<meshStandardMaterial color="#7cc4ff" emissive="#1b4a7a" emissiveIntensity={0.5} roughness={0.12} />
</mesh>
{[0, 1, 2].map((i) => {
const a = (i / 3) * Math.PI * 2;
return (
<mesh
key={i}
castShadow
position={[Math.sin(a) * 0.52, -0.78, Math.cos(a) * 0.52]}
rotation={[0.32, -a, 0]}
>
<boxGeometry args={[0.07, 0.62, 0.5]} />
<meshStandardMaterial color="#e5484d" roughness={0.45} />
</mesh>
);
})}
<mesh position={[0, -1.0, 0]} castShadow>
<cylinderGeometry args={[0.3, 0.42, 0.32, 32]} />
<meshStandardMaterial color="#2a3346" roughness={0.6} metalness={0.3} />
</mesh>
</group>
);
}
Two things worth noticing:
- The porthole glows because its material has an
emissivecolor. Emissive light is not affected by the scene lights, so it reads as a lit window. - The fins are a loop. Mapping over
[0, 1, 2]and using a little trigonometry places three fins evenly around the base, instead of copy-pasting three nearly-identical meshes.
4. The spin: useFrame
This is the part people overthink. There is no requestAnimationFrame, no useEffect, no state. useFrame is fiber’s render loop: the callback runs every frame, and its second argument delta is the seconds since the last frame. Multiplying by delta makes the rotation frame-rate independent — the rocket turns at the same speed on a 60Hz laptop and a 120Hz monitor.
useFrame((_, delta) => {
if (ref.current) ref.current.rotation.y += delta * 0.6;
});
5. The scene: one Canvas
The Canvas component is the whole engine. That single tag builds the WebGL renderer, the scene, and the camera. Everything inside it is the scene graph. Turn shadows on with the shadows prop, position the camera, then add lights, the rocket, a ground plane, and the controls.
function App() {
return (
<Canvas shadows camera={{ position: [0, 1.1, 6.2], fov: 38 }}>
<color attach="background" args={["#0b1020"]} />
<ambientLight intensity={0.55} color="#bcd3ff" />
<directionalLight position={[4, 6, 4]} intensity={1.6} castShadow />
<directionalLight position={[-5, 2, -4]} intensity={0.7} color="#61dafb" />
<Rocket />
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -1.9, 0]} receiveShadow>
<planeGeometry args={[60, 60]} />
<meshStandardMaterial color="#141b30" />
</mesh>
<OrbitControls enableDamping minDistance={3.5} maxDistance={11} target={[0, 0.1, 0]} />
</Canvas>
);
}
createRoot(document.getElementById("root")!).render(<App />);
A real shadow needs three flags on three different objects, and missing any one means nothing appears:
shadowson theCanvas(turns the renderer shadow map on).castShadowon the light and on every mesh that should cast.receiveShadowon the ground that catches it.
And OrbitControls is the entire interaction layer. One self-closing tag from drei gives you drag-to-rotate and scroll-to-zoom, with damping for that smooth glide.
6. Run it
npm run dev
Open the URL Vite prints, and the rocket is spinning. Drag to rotate it, scroll to zoom.
From primitives to a real model
We built the rocket from primitives to keep everything in one file, but the structure is identical for a downloaded model. Drop a .glb into public/, and drei’s useGLTF loads it:
import { useGLTF } from "@react-three/drei";
function Model() {
const { scene } = useGLTF("/rocket.glb");
const ref = useRef<THREE.Group>(null);
useFrame((_, delta) => { if (ref.current) ref.current.rotation.y += delta * 0.6; });
return <primitive ref={ref} object={scene} />;
}
The spin, the lights, the camera, and the controls do not change. That is the point: once the Canvas and the render loop are in place, the model is the only moving part.
Takeaways
- The
Canvasis the whole engine. It builds the renderer, scene, and camera, so you only describe the scene graph as JSX. useFrameis the animation loop. Rotate a ref a little every frame, scaled bydelta, and the spin is smooth and frame-rate independent.- A model is just meshes in a group. Wrap them and the whole thing rotates, scales, and moves as one — whether the parts are primitives or a loaded
.glb.
Full source is on GitHub. This is part of the Build It in the Browser series on Codegiz — AI-produced, human-reviewed.