DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

I Built a 3D Solar System in 300 Lines of React (No Game Engine)

Pull up a browser. Drag your mouse. Watch eight planets orbit the Sun, axes tilted, Saturn's rings catching the light.

That's not a game engine. That's not Unity. That's 300 lines of React.

If your mental model of "3D programming" is "scary C++ matrices and a 600-page OpenGL textbook," you're a decade out of date. WebGL has shipped in every browser since 2014. Three.js wraps the boring math. React Three Fiber lets you write the scene as components, the same way you write HTML. The whole pipeline is <mesh>, <sphereGeometry>, <meshStandardMaterial> — three tags, you've made a planet.

Today I'll show you the whole thing.

The core insight: a scene is a tree

Every 3D scene — every Pixar movie, every video game, every product configurator — is the same shape: a tree of objects, where each node has a position, a rotation, a scale, and zero or more children. That's it. The Sun is the root. Earth is a child positioned 9 units to the right. The Moon is a child of Earth, positioned 1 unit further right. Rotate Earth and the Moon comes along for the ride, because it's a child.

In Three.js you build this tree imperatively:

const sun = new THREE.Mesh(geom, mat);
const earth = new THREE.Mesh(geom2, mat2);
earth.position.x = 9;
sun.add(earth);
scene.add(sun);
Enter fullscreen mode Exit fullscreen mode

In React Three Fiber, the tree IS your component tree:

<mesh>           {/* sun */}
  <sphereGeometry />
  <meshBasicMaterial color="yellow" />
  <mesh position={[9, 0, 0]}>    {/* earth, child of sun */}
    <sphereGeometry args={[0.5]} />
    <meshStandardMaterial color="blue" />
  </mesh>
</mesh>
Enter fullscreen mode Exit fullscreen mode

That's the whole conceptual leap. Once you see "the React tree is the Three.js scene graph," the rest is naming things.

The trick that makes orbits cheap

Naïve orbit code looks like this:

useFrame((_, dt) => {
  angle += speed * dt;
  earth.position.x = Math.cos(angle) * 9;
  earth.position.z = Math.sin(angle) * 9;
});
Enter fullscreen mode Exit fullscreen mode

That works, but you're doing two trig calls per planet per frame in JavaScript. 60 fps × 8 planets = 960 sin/cos per second in slow JS.

There's a better way. Put the planet inside a pivot group at the origin. Place the planet at (distance, 0, 0). Rotate the group, not the planet.

<group ref={orbitRef}>                    {/* this group spins → orbit */}
  <mesh position={[distance, 0, 0]}>      {/* planet stays put in local space */}
    <sphereGeometry args={[radius]} />
    <meshStandardMaterial color={color} />
  </mesh>
</group>

useFrame((_, dt) => {
  orbitRef.current.rotation.y += speed * dt;   // ONE addition
});
Enter fullscreen mode Exit fullscreen mode

Now you're doing one addition per planet per frame in JavaScript and zero trig. Three.js's internal matrix update handles the rotation in compiled C++ inside the GPU pipeline. The math still happens — it just happens in the right place.

Same trick for axial rotation: a child group inside the planet rotates on its own Y axis. Tilt the wrapper group on the X axis and Uranus is suddenly tipped 98° like real Uranus. The whole solar system is six nested groups doing addition.

Lighting: three lines, instantly 3D

If you skip lighting, every planet looks flat — like a coloured paper disc. Add one <pointLight> at the Sun's position and use meshStandardMaterial for the planets:

<pointLight position={[0, 0, 0]} intensity={2.5} distance={120} />

<mesh>
  <sphereGeometry args={[0.5]} />
  <meshStandardMaterial color="blue" roughness={0.7} />
</mesh>
Enter fullscreen mode Exit fullscreen mode

meshStandardMaterial is physically-based — it reads the light, bounces it off the surface based on roughness and metalness, and shades the half facing the light bright while the half facing away goes dark. Three lines. Instant 3D.

Pro tip: don't use meshStandardMaterial for the Sun itself. The Sun emits light, it doesn't receive it. Use meshBasicMaterial, which ignores all lights and shows the colour you set, flat. Otherwise you'll have a yellow sphere with a dark side, which looks wrong.

OrbitControls: 80% of the polish for free

Drei (the R3F helper library) ships an <OrbitControls /> component. Drop it in your <Canvas> and you get:

  • Drag to rotate the camera around the scene
  • Scroll to zoom
  • Pinch on mobile
  • Two-finger rotate
<OrbitControls enablePan={false} minDistance={6} maxDistance={80} />
Enter fullscreen mode Exit fullscreen mode

Three lines, all of "drag to look around" is done. This is the kind of thing that takes a junior developer two weeks in raw WebGL and 30 seconds in R3F. Use the helpers.

HTML overlays beat in-canvas UI

The temptation when you're new to 3D is to put every UI element inside the 3D scene — billboards, sprites, text geometry. Don't. Mount your <Canvas> full-bleed and stack regular HTML on top with position: absolute.

<div className="shell">
  <header className="hero">...</header>
  <Canvas>...</Canvas>
  <aside className="info-panel">...</aside>
</div>
Enter fullscreen mode Exit fullscreen mode

The info panel that slides in when you click a planet is just a styled <aside>. The speed slider is <input type="range">. Your CSS skills transfer 1:1. The 3D part stays focused on 3D.

What I learned actually building this

Real takeaways from an afternoon of this:

1. Three.js is huge but the surface you need is small. The full Three.js bundle is ~600KB. You will use maybe 12 of its 400+ classes. Scene, Mesh, SphereGeometry, MeshStandardMaterial, PointLight, PerspectiveCamera, OrbitControls. That's most of it.

2. Real scale is the enemy. The Sun is 109× the radius of Earth. Neptune orbits ~30× further than Earth. If you use real ratios, the Sun fills the screen and Neptune is a single pixel. Cheat the visuals. Show real numbers in the info panel.

3. useFrame runs 60Hz, so don't allocate. Every frame, that callback fires. If you new Vector3() inside it, you're creating garbage 60 times per second. Either mutate refs you already have, or hoist allocations outside.

4. delta is your friend. R3F's useFrame((_, delta) => ...) gives you seconds since last frame. Multiply your speed by delta and your animation runs the same on a 60Hz laptop and a 144Hz gaming monitor. Without delta, your planets fly off the screen on a high-refresh display.

5. dpr={[1, 2]} is the mobile performance switch. Devices with retina displays would normally render at 3× resolution and tank the FPS. Capping at 2× looks identical to the eye and triples your frame rate on phones.

Why this matters

3D in the browser used to be a specialty — game studios, big agencies, NASA visualizations. It's not specialty anymore. Product configurators, real estate walkthroughs, data visualizations, NFT galleries, classroom physics demos — every web product is starting to have a 3D moment.

R3F is the lever that makes 3D approachable for people who already write React. You don't have to learn imperative scene-graph plumbing. You already know how trees of components work — you're doing 3D, you just have a different leaf type.

So go play. Open the live demo, click each planet, scroll out and look at the layout from the side. Then clone the repo and change a number. Make the Sun blue. Add a moon to Earth — wrap an Earth-sized sphere in an outer group, position the moon (1.2, 0, 0), and watch it follow. That's the entire mental model. You'll be making your own scenes within an hour.

Try it / fork it

🌐 Live: https://threejs-from-zero.vercel.app
🐙 Code: https://github.com/dev48v/threejs-from-zero

This is Day 36 of TechFromZero — a 50-day series where I build one tech from scratch every day with step-by-step commits you can read like a textbook. Yesterday was a voice AI tutor (Web Speech → Gemini → TTS). Tomorrow we're building a multi-agent AI orchestration that has agents argue with each other.

🌐 See all days: https://dev48v.infy.uk/techfromzero.php

Talk to you tomorrow.

Top comments (1)

Collapse
 
coridev profile image
Cor E

this is why I love React :D Def deserves a ♥️