Build a 3D Product Page in the Browser

Render live 3D product geometry with three.js and deploy it to a public URL.

Introduction

30 Second Summary

Most product pages sell you something with a flat photo or a video that has to finish loading before anything moves. The good ones let you spin the product around and look at it from any angle.

In this project, you will build a one page site where the product is real 3D geometry drawn live in the browser with three.js. You will then deploy it to a free host and open the live URL on your phone.

What You'll Build

You will finish with a link you can send to a friend, and when they open it the product spins on screen, breaks into its parts and shifts under new lighting as they scroll.

By the end of this project, you'll have:

  • A live URL you can open on your phone, where the product draws itself as real geometry instead of a downloaded image.
  • An exploded view that pulls the product apart into its pieces so you can show what sits inside it.
  • A scene that reacts to scroll, with the camera moving as a visitor reads down the page.
  • Secret Mission: Add a reduced motion fallback so the scene holds still for visitors who have switched animation off in their system settings.

Are there any prerequisites?

You need a laptop with Node installed, a modern browser, and a free account on any static host. No graphics card is needed, because WebGL runs on whatever integrated graphics your laptop already has.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters. You'll be putting a real 3D product page on the internet, so it's worth naming the goal before you write a single line of code.

Create Your Vite Project

The product on your page will be real geometry, redrawn by the browser every single frame. That geometry comes from three.js, which ships as a set of JavaScript modules.

A browser cannot pull those modules straight off your hard drive when you double-click an HTML file. You need a dev server sitting in front of them, assembling the modules and refreshing the page the moment you change a line.

Vite is that dev server. Here you scaffold a fresh Vite project and get its starter page rendering on your own machine.

In this step, get ready to:
  • Scaffold a fresh Vite project from the vanilla template.
  • Install the project's dependencies with npm.
  • Run the dev server and open your local URL in the browser.
Scaffold a fresh Vite project
  • Switch back to your terminal.
  • Move to your Desktop by running this command:
cd ~/Desktop

What the scaffolder will ask you

npm downloads the create-vite package before it can run. If it asks permission to install that package, press Enter to accept.

Naming the folder and the template up front skips the framework picker. If the scaffolder still offers to install dependencies and start the server for you, decline it. You do both yourself in a moment.

  • Scaffold the project by running this command:
npm create vite@latest three-product-page -- --template vanilla

What does this command do?

  • npm create vite@latest fetches the newest Vite scaffolder and runs it.
  • three-product-page is the folder it creates for your project.
  • --template vanilla picks the plain JavaScript starter, with no framework layered on top.
  • The bare -- tells npm to hand everything after it to the scaffolder instead of reading it itself.
  • Confirm the folder was created by running this command:
ls

You should see three-product-page listed alongside whatever else lives on your Desktop.

Nothing scaffolded?

  • A folder already sitting at three-product-page makes the scaffolder stop and ask before it overwrites anything. Choose a different folder name if you want to keep the old one.
  • A missing -- separator makes npm swallow the template flag, so you land back in the framework picker. Copy the command again exactly as written above.

Why did the Vite scaffolder fail in my terminal?

Install the project's dependencies

The scaffolder wrote a package.json naming every package your project depends on. It did not download a single one of them.

  • Move into your new project folder by running this command:
cd three-product-page
  • Download every package the project depends on by running this command:
npm install

npm prints a summary line telling you how many packages it added and how long it took. A node_modules folder now sits next to your project files.

Install stalling or erroring?

  • Check that your terminal prompt shows you inside three-product-page before you install. Installing from your Desktop pulls down nothing useful.
  • A dropped connection or an office proxy can stall the download partway through. Run the same command again once you are back online.

Why is my install failing in this project?

Start the dev server and open your page

Vite serves your project over a local address on your own machine. It keeps watching your files and pushes every save straight into the open page.

Before you run the next command, have a guess: does the terminal hand your prompt back when the server starts, or does it stay busy?

  • Start the dev server by running this command:
npm run dev

The server takes over this terminal

The dev server runs until you stop it, so this terminal never returns to your normal prompt.

Press Ctrl+C to stop it. Open a second terminal tab if you want to run other commands while the server stays up.

Vite prints a Local address, usually http://localhost:5173.

  • Record the Local address your terminal printed: your local Vite URL
  • Open that address in your browser.

You should see the Vite starter page, with the Vite logo and a counter button.

  • Click the counter button a few times.

The number climbs and the page never reloads. Your project is being served as live JavaScript, which is exactly what three.js will need in the next step.

Blank page in the browser?

  • Another program already holding port 5173 makes Vite quietly pick a different number. Read the Local address in your terminal again and use the one it actually printed.
  • An address typed by hand is easy to fumble. Copy the Local address straight out of the terminal instead.

Why is my dev server running but showing nothing?

Your project is live at a local address and reloading on every save. Next up, you bring in three.js and watch your first 3D object appear on that page.

Add Three.js and a ThreeUI Component

Your Vite dev server is running and the starter page is loading from your own machine. Everything on that page is still flat HTML.

Drawing a real object in a browser means talking to WebGL, which is a low level API that expects you to hand it raw geometry and shaders. three.js wraps all of that in objects you can read, like a scene, a camera and a light.

In this step you'll add three.js to the project and drop in a ThreeUI style component that builds a scene, a camera, a renderer and one placeholder shape. By the end, that shape is spinning on your local URL.

In this step, get ready to:
  • Install three.js into your Vite project.
  • Pick a ThreeUI component to build toward.
  • Render a placeholder 3D shape and set it spinning.
Install three.js

three.js ships as a package, so it lives alongside the rest of your project's dependencies. Your dev server is holding onto your terminal, so the install needs its own tab.

  • Press Cmd+T (macOS) or Ctrl+Shift+T (Windows) to open a second terminal tab, so the dev server keeps running in the first one.
  • Move into your project folder by running this command:
cd ~/Desktop/[[PROJECT_FOLDER="your project folder name"]]
  • Install three.js by running this command:
npm install three
  • Open package.json from your project folder in your editor.
  • Check the dependencies section. You should see three listed there with a version number next to it.

Install didn't finish?

If your terminal says it cannot find a package.json, the second tab opened somewhere other than your project folder. Run the cd command above again before installing.

If the install stalls, check that your first terminal tab is still the one running the dev server. Running two npm commands in the same tab will interrupt it.

Still stuck? Why won't npm install three in my Vite project?

Pick the ThreeUI component you want to build toward

ThreeUI is an open source gallery of ready made three.js pieces, from spinning wireframe shapes to full scenes. Browsing it now gives you a target for the product you'll build in the next step.

  • Open threeui.com in a new browser tab.
  • Scroll the gallery until you find a shape close to the product you want to show off.
  • Pick one that is not labelled PRO, since those need a paid plan.
  • Record the name of the component you picked: your ThreeUI component name.

Why build the structure with three.js

Every ThreeUI component is built on the same three.js foundations: a scene, a camera, a renderer and a mesh. Build those yourself once and any component you drop in later reads like something you already understand.

ThreeUI is also brand new, with no release history behind it. Anything structural in this project leans on three.js APIs, which have been stable for years.

Build your scene and set it spinning

A three.js component always starts with the same trio. A scene to hold your objects, a camera to look at them, and a renderer to draw the result onto a canvas.

  • In your editor, open index.html from your project folder.
  • Find the <script type="module"> line near the bottom of the file.

The src on that line points at your project's entry file, the JavaScript that Vite loads first. That file is where your component goes.

  • Open that entry file in your editor.
  • Delete everything inside it, including the starter imports.
  • Build your scene, camera and renderer by pasting this code into the empty file:
import * as THREE from "three";

// The scene holds every object, light and camera in your 3D world
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b0b0f);

// The camera decides which slice of that world ends up on screen
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.z = 5;

// The renderer draws the scene onto a canvas and puts that canvas on the page
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.style.margin = "0";
document.body.appendChild(renderer.domElement);

// One draw call paints the empty scene so you can see the canvas is live
renderer.render(scene, camera);

What does this code do?

  • scene is the container. Anything you want drawn has to be added to it.
  • camera is your viewpoint, sitting 5 units back so there is room for an object in front of it.
  • renderer creates the canvas element, sizes it to the window, and appends it to the page.
  • renderer.render(scene, camera) draws one frame. Right now the scene is empty, so that frame is just the background colour.
  • Save the entry file.

Vite reloads the page for you. The starter content disappears and the whole window turns near black, edge to edge, because your canvas now fills it.

Still seeing the Vite starter page?

Check that you emptied the entry file the <script type="module"> line points at, and not a different file with a similar name.

If the page is blank rather than dark, open your browser console. An import error there usually means three.js was installed into a different folder than the one your dev server is serving.

Need a hand? Why is my three.js canvas not showing up on the page?

  • In your entry file, find the line at the bottom that reads renderer.render(scene, camera);.
  • Add your placeholder product and its lighting by pasting this code above that line:
// A mesh is a shape (geometry) wrapped in a surface (material)
const geometry = new THREE.BoxGeometry(1.2, 1.2, 1.2);
const material = new THREE.MeshStandardMaterial({ color: 0x5b8def, roughness: 0.35 });
const product = new THREE.Mesh(geometry, material);
scene.add(product);

// MeshStandardMaterial stays black until light lands on it
const keyLight = new THREE.DirectionalLight(0xffffff, 3);
keyLight.position.set(2, 3, 4);
scene.add(keyLight);
scene.add(new THREE.AmbientLight(0xffffff, 0.4));

What's happening here?

  • geometry is the shape's raw form, a box measuring 1.2 units on each side.
  • material is the surface. A low roughness gives it a slight sheen instead of a flat matte finish.
  • product combines the two into a mesh, which is the thing the renderer can actually draw.
  • keyLight throws light from the top right for shape and shadow. The ambient light lifts the dark side so it never goes fully black.
  • Save the entry file.

A blue cube appears in the middle of the dark page, brighter on the top right corner where the key light hits it. It sits perfectly still.

Cube nowhere to be seen?

Check where you pasted the code. If it landed below renderer.render(scene, camera);, the frame was drawn before the cube existed.

If you can see a dark silhouette but no colour, one of the two lights is missing from the scene. Both need their own scene.add call.

Want a second opinion? Why is my three.js mesh invisible?

  • In your entry file, find this line at the very bottom:
renderer.render(scene, camera);
  • Replace that single line with this animation loop, so the cube is redrawn every frame:
// The loop runs once per frame: nudge the rotation, then draw
function animate() {
  requestAnimationFrame(animate);
  product.rotation.y += 0.01;
  product.rotation.x += 0.004;
  renderer.render(scene, camera);
}

animate();

Before you save, predict what you are about to see. Which way will the cube turn, and will it ever come to a stop?

  • Save the entry file.

The cube spins steadily around its vertical axis with a slow tilt forward. It keeps going for as long as the tab is open.

What does the loop do?

requestAnimationFrame asks the browser to call animate again just before the next frame is painted, usually 60 times a second.

Rotation is measured in radians, so 0.01 per frame works out at a full turn every ten seconds or so. Change that number and the speed changes with it.

Cube frozen or the page gone blank?

A frozen cube means the old renderer.render(scene, camera); line is still there and the loop never replaced it. Delete the stray line.

A blank page usually means animate(); is missing, so the function was defined but never called.

Still not moving? Why isn't my three.js animation loop running?

✔️ Awesome, I've got everything!

Great, double check your entry file is saved and the cube is still turning in the browser.

ⓧ I'd like to double check the full code

Scroll up and confirm your entry file holds these three pieces, in this order:

  • The scene, camera and renderer block, starting with import * as THREE from "three";.
  • The mesh and lighting block, starting with const geometry.
  • The animation loop, ending with animate(); on the last line.

Your page is drawing live geometry now, lit and spinning on your own machine. Next up, you'll replace that placeholder cube with the shape of your own product and pull its parts apart into an exploded view.

Build the Exploded Product View

Your ThreeUI component is on screen and spinning, which proves the scene, camera and renderer are all wired up correctly.

That shape is still a stand-in. A product page needs the actual product, and the best product pages show off how the thing is put together.

In this step you'll swap the placeholder for a three part shape built from three.js geometry. Then you'll pull those parts away from each other so the page renders an exploded view.

In this step, get ready to:
  • Replace the placeholder geometry with a multi-part product shape.
  • Group the parts so they rotate together as one object.
  • Move the parts apart so the scene renders as an exploded view.
Replace the placeholder with your product's parts

Your product is made of three pieces: a body, a cap and a ring. Each piece has to be its own mesh so you can move it on its own later.

  • In your code editor, open the ThreeUI component file from the last step.
  • Find the block that creates the placeholder shape. It looks like this:
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshNormalMaterial();
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

How to spot the placeholder block

The exact shape and variable names in your copy may differ, so match on the pattern instead of the letters.

You're looking for a geometry, a material, a mesh built from those two, and a line that adds the mesh to the scene.

  • Replace those four lines with the code below to build your product from three separate parts:
// A Group is a container, so everything inside it moves and rotates as one
const productGroup = new THREE.Group();

// Colours each face by the direction it points, so parts show up without lights
const partMaterial = new THREE.MeshNormalMaterial();

// The three pieces of the product, each its own mesh
const body = new THREE.Mesh(new THREE.CylinderGeometry(0.8, 0.8, 1.2, 48), partMaterial);
const cap = new THREE.Mesh(new THREE.CylinderGeometry(0.85, 0.85, 0.15, 48), partMaterial);
const ring = new THREE.Mesh(new THREE.TorusGeometry(0.9, 0.08, 16, 60), partMaterial);

// A torus is drawn standing upright, so lay it flat around the body
ring.rotation.x = Math.PI / 2;

productGroup.add(body, cap, ring);
scene.add(productGroup);

What does this code do?

  • new THREE.Group() creates an empty container. Anything you add to it moves and rotates as a single object.
  • MeshNormalMaterial needs no lights at all, so your parts stay visible until you add real lighting later.
  • Each piece is its own THREE.Mesh, which is what lets you move one without dragging the others with it.
  • Math.PI / 2 is a quarter turn in radians, which tips the ring flat so it circles the body.
  • scene.add(productGroup); drops the whole group into the scene, the same way the placeholder was added.

One reference to the old placeholder is still hanging around in the animation loop. Point it at your group before you reload.

  • Scroll down to the animation loop and find the line that adds to the placeholder's rotation:
mesh.rotation.y += 0.01;
  • Swap the placeholder's name for productGroup, so the line now looks like this:
productGroup.rotation.y += 0.01;

Before you look at the browser, what do you think three parts created at the same position will look like on screen?

  • Save the file.
  • Switch back to your browser tab.

You'll see one chunky cylinder rotating with a thin band around its middle. The cap is swallowed completely inside the body.

That is the intended result, and it is exactly the problem an exploded view solves.

Page gone black?

  • Check that every mention of the old placeholder name is gone. A leftover reference in the animation loop stops the whole scene rendering.
  • Check the name your component uses for the scene. If it isn't scene, use that name in the add line instead.
  • Still stuck? Why is my three.js canvas rendering black?
Move the parts apart into an exploded view

An exploded view pulls a product into its pieces along one axis. The gaps are what let someone see how the thing is assembled.

  • Separate the parts by adding these two lines directly above the scene.add(productGroup); line:
// Lift the cap above the body and drop the ring below it
cap.position.y = 1.1;
ring.position.y = -0.9;

What's happening here?

  • position.y moves a mesh up and down the vertical axis, measured from the centre of the scene.
  • The body keeps its default position of zero, so it stays put while the other two pull away from it.
  • The positions are set on the parts, not on the group, so the group still spins all three together.
  • Save the file.
  • Switch back to your browser tab.

The three parts now float apart in a vertical stack with clear gaps between them, still turning together as one object.

Parts still overlapping?

  • Make sure the two lines sit below where cap and ring are created. A mesh can't be positioned before it exists.
  • Check you wrote cap.position.y and not cap.y, which does nothing.
  • Still bunched up? Why aren't my meshes moving when I set position.y?
Control the spacing with one value

Right now the gap is baked into two separate numbers. Pulling it into one value lets you tune the whole explosion with a single edit.

  • In the ThreeUI component file, find the two position lines you just added:
cap.position.y = 1.1;
ring.position.y = -0.9;
  • Replace them with a shared spacing value, so the lines now look like this:
// One value controls how far apart the parts sit
const explodeDistance = 1.1;

cap.position.y = explodeDistance;
ring.position.y = -explodeDistance;
  • Save the file.
  • Switch back to your browser tab.

The stack looks almost identical, with the ring sitting a little lower than before. The spacing is now driven by one number.

Time for the real test. Before you change anything, what do you expect a bigger spacing value to do to the rotating stack?

  • Change explodeDistance from 1.1 to 1.8.
  • Save the file.
  • Switch back to your browser tab.

The cap and the ring slide further from the body and the gaps widen, while the whole group keeps rotating in step.

One number now controls how hard your product explodes.

Console complaining about explodeDistance?

  • The const explodeDistance line has to sit above both position lines that use it.
  • Check the spelling matches in all three places, since JavaScript names are case sensitive.
  • Need a hand reading the error? What does my browser console error mean?

✔️ Awesome, I've got everything!

Great. Double check you've saved the ThreeUI component file and that all three parts are visible in the browser.

ⓧ I'd like to double check the full code

Scroll up and confirm each of these is in your ThreeUI component file, in this order:

  • The productGroup container and the partMaterial used by every part.
  • The three meshes body, cap and ring, plus the quarter turn on the ring.
  • The explodeDistance value with the two position lines below it.
  • productGroup.add(body, cap, ring); followed by the line that adds the group to the scene.
  • The animation loop rotating productGroup instead of the old placeholder.

Your product is in pieces and spinning. Next up, you'll light the scene and swap that stand-in material for a surface that actually looks like a product.

Relight and Retexture the Scene

Your product is on screen and spinning, with its parts pulled apart so you can see how it fits together. Right now it is lit by whatever lights the ThreeUI component shipped with.

Default lighting makes every surface look like the same grey clay. In this step you take over the lights and the surface finish, so the product reads as a manufactured object instead of a placeholder.

Lighting is the part of a three.js scene that sells the shape. The same geometry can look flat or expensive depending on where the light comes from.

In this step, get ready to:
  • Save a screenshot of the current render as your before shot.
  • Replace the scene lighting with your own key, fill, and rim lights.
  • Give the product a metallic surface and compare the two renders.
Save a before shot of the current render

Lighting changes are easy to talk yourself into liking. A saved screenshot gives you something concrete to hold the new render against.

  • Switch back to the browser tab showing your dev server.
  • Press Cmd+Shift+4 (macOS) or Windows+Shift+S (Windows) to start a screen capture.
  • Drag a box around the canvas so the whole product sits inside it.
  • On Windows, click the capture notification that appears in the corner.
  • On Windows, save the image to your Desktop with the name before-lighting.png.
  • On macOS, find the new screenshot already sitting on your Desktop and rename it to before-lighting.png.
  • Open your Desktop folder in Finder (macOS) or File Explorer (Windows).

You should see before-lighting.png listed there, showing the grey version of your product.

Replace the scene lighting

Product shots are usually lit with three lights doing three different jobs. A key light picks the main angle, a fill light stops the shadows going solid black, and a rim light traces the outer edges.

  • Switch back to your ThreeUI component file in your code editor.
  • Scroll to the part of the component that builds its lights, where the lines mention Light.
  • Replace those light lines with your own three-light rig by pasting this in their place:
// Soft fill so no face of the product drops to pure black
const fillLight = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(fillLight);

// Key light, thrown in from the upper right
const keyLight = new THREE.DirectionalLight(0xffffff, 3);
keyLight.position.set(3, 4, 2);
scene.add(keyLight);

// Cool rim light from behind to trace the edges
const rimLight = new THREE.DirectionalLight(0x88aaff, 1.5);
rimLight.position.set(-4, 1, -3);
scene.add(rimLight);

What does this lighting do?

  • `AmbientLight` adds the same amount of light to every surface, so shadowed faces stay readable.
  • A `DirectionalLight` behaves like the sun, so its position sets the angle light arrives from rather than how far away it sits.
  • The rim light's blue tint at `0x88aaff` separates the product's outline from the background.
  • Save the file.
  • Switch back to the browser tab with your dev server.

The page reloads on its own. Your product now has a bright side facing the upper right and a faint blue edge along the far side as it turns.

Scene gone completely dark?

  • Check that `scene` matches the variable name the component already uses for its three.js scene. Rename it in all three `scene.add()` calls if it differs.
  • Confirm you copied the intensity numbers across. An intensity of `0` renders as no light at all.
  • Check your editor for a red squiggle on `THREE` in case the import line at the top of the file was deleted along with the old lights.

My three.js scene renders black after changing the lights

Give the product a metal surface

Lights only matter if the surface reacts to them. A standard material does exactly that, using two dials called roughness and metalness to describe the finish.

  • Scroll to where the parts of your shape get their material, where the lines mention Material.
  • Define one shared surface for the whole product by adding this above those lines:
// One shared surface so every part of the product matches
const shellMaterial = new THREE.MeshStandardMaterial({
  color: 0xb0b6c0,
  metalness: 0.9,
  roughness: 0.25,
});

What do roughness and metalness do?

  • `metalness` at `0.9` tells the renderer to treat the surface like brushed steel, reflecting the lights around it instead of scattering them.
  • `roughness` at `0.25` keeps those reflections tight, so highlights stay small and sharp.
  • Push `roughness` toward `1` later if you want a matte, chalky finish instead.
  • Pass shellMaterial into each new THREE.Mesh() call in place of the material it uses now.

Before you save, make a call. With metalness this high, do you expect the product to look brighter overall or darker than the grey version?

  • Save the file.
  • Switch back to the browser tab with your dev server.

Parts of it look darker than before, with hard bright streaks where the key light catches an edge. A metal surface shows almost nothing except what it reflects, so faces pointing away from your two lights fall into shadow.

Product turned almost entirely black?

  • Drop `metalness` to `0.5` and save again. A fully metallic surface with no reflections around it has very little to bounce back.
  • Check that every mesh now receives `shellMaterial`. A part still holding the old material will look obviously different from its neighbours.
  • Raise the key light intensity from `3` to `5` if the highlights are there but too faint to read.

Why does my MeshStandardMaterial look black at high metalness?

  • Open before-lighting.png from your Desktop.
  • Drag that window next to your browser so both renders are visible at once.

Answer one question from the pair: which version shows the separation between the exploded parts more clearly? The lit metal version should win, because the highlights and shadows now describe each surface angle.

✔️ Awesome, I've got everything!

Double check you saved your component file, then move on.

ⓧ I'd like to double check the full code

Scroll up and confirm both additions are present in your component file, in this order:

  • The three-light rig with `fillLight`, `keyLight`, and `rimLight`, each added to the scene.
  • The `shellMaterial` definition, passed into every mesh that makes up your product.

Your product now looks like an object under real light instead of a grey mould. Next up, you'll wire the camera to the scroll position so the scene moves as the page does.

Link the Camera to Scroll Position

Your product now sits under lighting and materials you picked yourself. It still spins away in its own world though, ignoring whoever is looking at it.

Scroll is the one input every visitor gives you for free. Wiring it to the camera turns a spinning object into a product that reveals itself as someone reads down the page.

The scene never has to move for this to work. three.js gives you a camera you can reposition on every frame, so you only change where the viewer stands.

In this step, get ready to:
  • Give the page three screens of scrolling room.
  • Capture the scroll position with a scroll event listener.
  • Move the camera on every frame using that position.
Give the page room to scroll

Your canvas fills the window and the page stops there, so there is nothing to scroll. A scroll listener with no scroll to listen to reports nothing.

  • In your editor, open index.html.
  • Add something to scroll towards by pasting this just above the closing </body> tag:
<section class="scroll-space">
  <h2>Scroll to explore the build</h2>
</section>

What does this section do?

  • The scroll-space class is the hook your stylesheet uses to give this block its height.
  • The heading gives you something visible that slides past the object as the page moves.
  • Save index.html.
  • Switch back to your browser. You should see Scroll to explore the build sitting below your 3D object.

Don't see the new heading?

  • Make sure you saved index.html. The dev server reloads the page on save.
  • Check the section sits inside <body> and not after the closing tag.
  • Still stuck? Why isn't my new HTML section showing up on the page?
  • In your editor, open your project's stylesheet, style.css.
  • Pin the canvas and stretch the page by adding these rules to the bottom of the file:
/* Glue the canvas to the viewport so the object stays in view while the page scrolls */
canvas {
  position: fixed;
  top: 0;
  left: 0;
}

/* Three screens of empty space, so there is plenty of scroll to react to */
.scroll-space {
  height: 300vh;
}

What does this CSS do?

  • position: fixed lifts the canvas out of the page flow. It stays locked to the window while everything else scrolls behind it.
  • height: 300vh makes the section three times the height of the window, which gives the camera a long runway to travel along.
  • Save style.css.
  • Switch back to your browser and scroll down. Your object stays fixed in the middle of the window while the heading slides up past it.

Object scrolling out of view?

  • If your stylesheet has a different name, add these rules to whichever CSS file your project already loads.
  • Check the canvas rule is not overridden further up the file by another rule setting position.
  • Still stuck? Why is my three.js canvas still scrolling with the page?
Capture the scroll position

The browser already tracks how far down the page you are. window.scrollY holds that number in pixels, and a scroll event tells you every time it changes.

  • In your editor, open main.js.
  • Track the scroll position in a variable by pasting this just below the import lines at the top of the file:
// How far down the page the visitor has scrolled, in pixels
let scrollOffset = 0;

// The browser fires a scroll event every time the page moves
window.addEventListener("scroll", () => {
  scrollOffset = window.scrollY;
  console.log("scrollOffset:", scrollOffset);
});

What does this code do?

  • scrollOffset is one number that any other part of your scene code can read.
  • The listener overwrites it whenever the page moves, so it always holds the latest position.
  • The console.log is temporary proof that the listener is firing at all.
  • Save main.js.
  • Right-click anywhere on the page in your browser and choose **Inspect**.
  • Click the Console tab.
  • Scroll the page slowly. You should see scrollOffset: lines appear with numbers climbing up from 0.

No numbers in the console?

  • Check the Console tab is selected and the filter box above it is empty.
  • If the page has no scrollbar, the .scroll-space rule did not apply. Confirm it is in the stylesheet your page loads.
  • Still stuck? Why isn't my window scroll event listener firing?
Drive the camera from the scroll offset

Scroll events can fire many times between two rendered frames, so moving the camera inside the listener asks the renderer to redraw more often than it can. Reading scrollOffset inside the frame loop instead keeps the camera in step with the frames the browser is actually drawing.

  • In main.js, scroll back up to the scroll listener you just added.
  • Find this line:
  console.log("scrollOffset:", scrollOffset);
  • Delete that line now that you have proof the listener works, so the listener looks like this:
window.addEventListener("scroll", () => {
  scrollOffset = window.scrollY;
});
  • Find the frame loop in main.js, the function that calls renderer.render(scene, camera).
  • Move the camera on every frame by pasting these lines directly above that renderer.render(scene, camera) line:
// Turn pixels scrolled into a 0 to 3 value, one unit per screen of scroll space
const scrollProgress = scrollOffset / window.innerHeight;

// Lift the camera and swing it sideways as the page scrolls
camera.position.y = scrollProgress * 2;
camera.position.x = Math.sin(scrollProgress) * 3;

// Re-aim at the centre of the scene so the product never drifts off frame
camera.lookAt(0, 0, 0);

What does this code do?

  • scrollProgress converts raw pixels into screens scrolled. The motion then feels the same on a tall monitor and a short laptop.
  • camera.position.y and camera.position.x move the viewpoint. Your product itself stays exactly where it is.
  • camera.lookAt(0, 0, 0) re-points the camera at the centre after every move, which keeps the object framed as the camera travels.

Before you save, make a call: as you scroll down, will your product appear to rise up the screen or sink down it?

  • Save main.js.
  • Switch back to your browser and scroll slowly from the top of the page to the bottom.

The camera climbs above the object and swings out to one side, so your product appears to sink and turn away as you scroll down. Scroll back up and it settles into its original framing.

Object not moving as you scroll?

  • Check the new lines sit inside the frame loop, above renderer.render(scene, camera) and not outside the function.
  • If your camera is named something other than camera, match the name used where the scene calls renderer.render.
  • Still stuck? Why isn't my three.js camera moving when I scroll?

Your page now reacts to the one thing every visitor does. Next up, you'll build the site and put it on a public URL you can open on your phone.

Deploy Your 3D Page

Your scene now answers to the scroll wheel, which means the last thing standing between you and a page you can send to someone is the fact that it only exists on your laptop.

The Vite dev server compiles your code on the fly and serves it to your machine only. A public host needs finished files it can hand to any browser in the world.

In this step you turn your project into those finished files, drop them on a free host, and load the result on your phone.

In this step, get ready to:
  • Turn your scene into a production build you can hand to a host.
  • Deploy those files to a free host and get a public URL.
  • Load that URL on your phone and watch the 3D scene render.
Build your scene into a production bundle

A host has no build tools of its own. It serves whatever files you give it, so you need a folder of plain HTML, CSS and JavaScript.

  • Press Ctrl+C in the terminal running your dev server to stop it.
  • Produce your finished files by running this command:
npm run build

What does this command do?

It bundles your JavaScript, your three.js imports and your styles into a handful of optimised files.

Everything lands in a new folder called dist. That folder is the entire website.

  • Confirm the build output exists by running:
ls dist
  • You should see index.html and an assets folder listed.

Build failed instead of finishing?

Read the last few lines of the terminal output. A build failure names the file and line number it choked on, which is usually the file you edited most recently.

Make sure every file is saved. The build reads what is on disk, not what is on screen in your editor.

My Vite build fails and I cannot work out which file is causing it

Built files are not the same as the ones the dev server was serving. Before you run the next command, do you think your scene will look identical, or break?

  • Serve the built files exactly as a host would by running this command:
npm run preview
  • Open http://localhost:4173 in your browser.
  • Scroll the page and confirm the object still rotates and still moves with your scroll.

Why preview before you deploy

The dev server is forgiving about file paths. A production build rewrites them, so a path that resolved happily a minute ago can miss.

This preview server serves the same files the host will serve. Catching a broken path here saves you a deploy and a confusing blank page.

Blank page in the preview?

Open your browser console and look for failed requests for files under assets. Those point at a path that only worked in development.

A canvas with no height also renders as nothing. Check that your canvas still has a height set in your CSS.

My scene works on the dev server but the preview build is blank

Deploy the dist folder to a free host

A static host copies your files to servers around the world and puts a URL in front of them. Netlify takes a folder by drag and drop, which is the shortest path from your Desktop to a public link.

  • Press Ctrl+C in your terminal to stop the preview server.
  • Go to https://app.netlify.com/login in your browser.
  • Sign up for a free account, or log in if you already have one.
  • Go to https://app.netlify.com/drop.
  • Open your project folder in Finder (macOS) or File Explorer (Windows).
  • Drag the dist folder onto the Drop zone on the page.

Why you drag dist and not the whole project

Netlify can detect a framework and run the build itself when you drop a source folder. You already ran the build and checked it, so dropping the output publishes exactly what you tested.

It is also far smaller. Your dependencies stay on your machine instead of crawling up your connection.

  • Wait for the upload to finish. Netlify shows a preview URL ending in netlify.app.
  • Record your live URL here: your netlify.app URL.
  • Open your live URL in a new browser tab.
  • Confirm the object appears and rotates, and that scrolling still moves it.

Deploy finished but the page is empty?

Check you dropped the dist folder itself and not a folder containing it. Netlify needs index.html at the top level of whatever you dropped.

If the upload stalled partway, drag the folder onto the Drop zone again. Each drop creates a fresh deploy.

My Netlify deploy went through but the live URL shows nothing

Open the live URL on your phone

A phone renders 3D on integrated graphics inside a screen a fraction the size of your laptop's. It is the honest test of whether this page is really shareable.

Before you load it, do you expect the scene to draw on your phone, or come up blank?

  • Open the browser on your phone.
  • Type your live URL into the address bar and load it.
  • Confirm the object draws on screen and keeps rotating.
  • Scroll the page with your thumb and confirm the object shifts as you scroll.

What just happened

Your phone downloaded a few hundred kilobytes of code and drew the geometry itself using WebGL.

No video streamed and no image downloaded. Anyone you send that link to gets the same live scene.

Nothing showing on the phone?

A canvas sized in viewport units can collapse to zero height on a phone. Give it an explicit height and redeploy.

Try a second browser on the phone. If one renders and the other does not, the problem is that browser's WebGL support rather than your code.

My three.js scene renders on desktop but not on my phone

Secret mission

Let Visitors Pause Your 3D Scene

Your product spins from the second the page loads, and it never stops. Add a motion toggle that reads the visitor's reduced motion preference, freezes both the rotation and the scroll camera when they want stillness, then redeploy so your live URL respects it too.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs on free hosting and a local project folder, so there are no ongoing costs.

Resources you used:

  • The local Vite project folder on your Desktop, including the dist/ production build.
  • The node_modules folder holding three.js and the rest of your dependencies.
  • The deployed site and its live public URL on your hosting provider.
  • The local dev server, if you left it running in your terminal.

Keep everything running

No action needed. Choose this if you're still actively building on the scene or want the live URL available to share.

  • Bookmark your live URL so you can send it to people later.
  • Leave your project folder on your Desktop so you can pick up the code again.

A 3D page is worth keeping

A live URL showing real geometry drawn in the browser is far more convincing than a screenshot of one.

Static hosting for a single page sits inside every free tier, so leaving it up costs you nothing.

Pause - I'll come back to this later

Shut down running processes to free up memory, and keep every file exactly where it is.

  • Return to the terminal running your dev server.
  • Press Ctrl+C to stop it.
  • Confirm your prompt is back and the local URL no longer loads in your browser.

Your deployed site stays live on its free tier, so you can return to the live URL any time.

Delete - I don't want to use this again

Remove all project resources and start fresh. Work through the deployment first, then the local files.

  • Return to the terminal running your dev server and press Ctrl+C to stop it.
  • Sign in to the dashboard of the host you deployed to.
  • Open the project you deployed for this site.
  • Open that project's settings page.
  • Scroll to the danger zone at the bottom of the settings page.
  • Select the option to delete the project, then confirm by typing the project name when prompted.
  • Open your live URL again. You should now get a not-found page instead of your 3D scene.

Can't find the delete option?

Every host words this differently, and the control usually sits at the bottom of the project's own settings page rather than your account settings.

Ask Where do I delete a deployed site on my hosting provider? if the dashboard has moved things around.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Confirm your project folder is there by running:
ls
  • Delete the project folder, including node_modules and dist/, by running this command on macOS or Linux:
rm -rf [[PROJECT_FOLDER="your project folder name"]]
  • On Windows, delete it by running this command instead:
rmdir /s /q [[PROJECT_FOLDER="your project folder name"]]
  • Run ls once more. Your project folder is gone from the listing.

Seeing no such file or directory?

The folder name has to match exactly, including capitals, so check the spelling against what ls printed.

You may also be in the wrong directory if you moved around after building. Run pwd to check where you are.

What about three.js and Node.js?

three.js was installed inside your project's node_modules folder, so deleting the project folder removes it too.

Node.js stays installed on your machine. It is useful for almost any future web project, so leaving it in place is the sensible call.

Nice Work!

Nice Work

Nice work. You've built a 3D product page that draws live geometry in the browser with three.js, and it's sitting at a public URL you can send to anyone.

That page loads real geometry on a phone screen, with no video file to download and no image standing in for the product.

You've learned how to:

  • Build a multi-part exploded view of your own product shape and keep it rotating live in the browser.
  • Re-light a scene and swap materials so the same geometry renders with a completely different look.
  • Drive the camera from scroll position so the object reacts as the visitor moves down the page.
  • Take on the Secret Mission and push your 3D scene beyond the base build.

Ready to quiz yourself?