Back to Blog

Add a Spinning 3D Model to Your React Site (R3F)

Celest KimCelest Kim

Video: Add a Spinning 3D Model to Your React Site (R3F) by CelesteAI

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

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 Canvas that 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.
  • OrbitControls from 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 OrbitControls comes 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 emissive color. 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:

  1. shadows on the Canvas (turns the renderer shadow map on).
  2. castShadow on the light and on every mesh that should cast.
  3. receiveShadow on 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

  1. The Canvas is the whole engine. It builds the renderer, scene, and camera, so you only describe the scene graph as JSX.
  2. useFrame is the animation loop. Rotate a ref a little every frame, scaled by delta, and the spin is smooth and frame-rate independent.
  3. 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.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.