Three.js in a single HTML5 file

three.js draws 3D scenes on an HTML canvas. One import map, one module script and about 30 lines of JavaScript give you a working scene with no install and no build step.

To run three.js in an HTML5 page, you need no install. Add an import map that says where three lives on a CDN, then write your code in a <script type="module">. three.js creates a <canvas> and draws your 3D scene into it.

Here is the whole thing in one file: a lit cube that turns.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>three.js in one HTML file</title>
<style>
  html, body { margin: 0; height: 100%; background: #111827; }
  canvas { display: block; width: 100%; height: 100%; }
  #ver {
    position: fixed; left: 10px; bottom: 8px;
    font: 12px system-ui, sans-serif; color: #9ca3af;
  }
</style>
<!-- 1. Tell the browser where "three" lives (pinned version) -->
<script type="importmap">
{
  "imports": {
    "three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js"
  }
}
</script>
</head>
<body>
<span id="ver"></span>

<!-- 2. Your code must be a module to use import -->
<script type="module">
  import * as THREE from 'three';

  // scene + camera + renderer: the three things every three.js page needs
  const scene = new THREE.Scene();
  const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 100);
  camera.position.z = 3;  // step back, or the camera sits inside the cube

  const renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.setPixelRatio(devicePixelRatio);  // sharp on phones
  renderer.setSize(innerWidth, innerHeight);
  document.body.appendChild(renderer.domElement);  // the <canvas>

  // a cube = geometry (shape) + material (surface)
  const cube = new THREE.Mesh(
    new THREE.BoxGeometry(1, 1, 1),
    new THREE.MeshStandardMaterial({ color: 0x22c55e })
  );
  scene.add(cube);

  // MeshStandardMaterial is black without light
  scene.add(new THREE.AmbientLight(0xffffff, 0.6));
  const sun = new THREE.DirectionalLight(0xffffff, 2);
  sun.position.set(2, 3, 4);
  scene.add(sun);

  // draw every frame
  renderer.setAnimationLoop((time) => {
    cube.rotation.x = time / 2000;
    cube.rotation.y = time / 1000;
    renderer.render(scene, camera);
  });

  // keep the shape correct when the window changes size
  addEventListener('resize', () => {
    camera.aspect = innerWidth / innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(innerWidth, innerHeight);
  });

  document.getElementById('ver').textContent = 'three.js r' + THREE.REVISION;
</script>
</body>
</html>
A complete three.js page: import map, module script, scene, camera, renderer, one cube and two lights.

Copy the code into an empty .html file and it runs as it is. The version is pinned in the address, so the page keeps working when a newer three.js comes out.

What three.js is

three.js is a JavaScript 3D library. Its own README describes the aim as an easy-to-use, lightweight, cross-browser, general-purpose 3D library. It ships WebGL and WebGPU renderers, and it is free under the MIT licence.

"HTML5" here simply means a modern web page. The page gives three.js a <canvas> element and the browser's WebGL support. three.js gives you objects such as scenes, cameras, shapes, materials and lights, so you do not write shader code by hand.

If you want to see what that saves you, WebGL in HTML draws a triangle with no library at all.

The smallest working file, part by part

Every three.js page follows the same order. The official guide puts it as three things you need: a scene, a camera and a renderer.

The four parts of a one-file three.js page, from top to bottom.
The four parts of a one-file three.js page, from top to bottom.
  1. Import map. A <script type="importmap"> in the head maps the name three to a full CDN address. MDN notes it must come before any module script that uses it.
  2. Module script. Only a type="module" script may use import. In a plain script tag, the browser stops at the import line.
  3. Scene, camera, renderer. The renderer creates a canvas. Append renderer.domElement to the body, and move the camera back from the centre.
  4. Draw loop. Nothing appears until renderer.render(scene, camera) runs. setAnimationLoop calls your function on every screen refresh.

The import map from the first example looks like this:

<script type="importmap">
{
  "imports": {
    "three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js"
  }
}
</script>

The version, 0.186.1, was the latest on npm on 1 October 2026. Any later version works the same way. Keep one number for every three.js file you load.

Old tutorials use three.min.js

Many older guides start with a plain <script src> pointing at build/three.min.js, then use a global THREE. The three.js migration guide says that build file, along with build/three.js, was removed in r161.

The old script-tag pattern fails on current versions. An import map and a module script replace it.
The old script-tag pattern fails on current versions. An import map and a module script replace it.

With a current version number, the old address returns 404 and the next line throws THREE is not defined.

The fix keeps the rest of your code. Swap the script tag for the import map above, and put import * as THREE from 'three'; at the top of a module script.

Why the model is black

Sometimes the shape is there, but it is a black silhouette. Materials such as MeshStandardMaterial calculate shading from lights. With no light in the scene, there is nothing to reflect.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Why is my three.js mesh black?</title>
<style>
  html, body { margin: 0; height: 100%; }
  body { display: flex; flex-direction: column; font-family: system-ui, sans-serif; background: #f4f5f7; }
  #view { flex: 1; min-height: 0; }
  canvas { display: block; width: 100%; height: 100%; }
  .bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; padding: 10px 12px; background: #fff; }
  button {
    font: inherit; font-size: 14px; padding: 8px 12px; border: 1px solid #d1d5db;
    border-radius: 8px; background: #fff; cursor: pointer;
  }
  button[aria-pressed="true"] { background: #0f766e; border-color: #0f766e; color: #fff; }
  #note { flex-basis: 100%; font-size: 13px; color: #374151; margin: 0; }
</style>
<script type="importmap">
{ "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js" } }
</script>
</head>
<body>
<div id="view"></div>
<div class="bar">
  <button id="light" aria-pressed="true">Lights: on</button>
  <button id="mat" aria-pressed="true">Material: Standard</button>
  <p id="note"></p>
</div>

<script type="module">
  import * as THREE from 'three';

  const view = document.getElementById('view');
  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0xe5e7eb);
  const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100);
  camera.position.set(0, 0.5, 5);

  const renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.setPixelRatio(devicePixelRatio);
  view.appendChild(renderer.domElement);

  // two materials for the same shape
  const standard = new THREE.MeshStandardMaterial({ color: 0xf97316 }); // needs light
  const basic = new THREE.MeshBasicMaterial({ color: 0xf97316 });       // ignores light
  const knot = new THREE.Mesh(new THREE.TorusKnotGeometry(0.8, 0.28, 128, 16), standard);
  scene.add(knot);

  const lights = new THREE.Group();
  lights.add(new THREE.AmbientLight(0xffffff, 0.5));
  const sun = new THREE.DirectionalLight(0xffffff, 2.5);
  sun.position.set(3, 4, 5);
  lights.add(sun);
  scene.add(lights);

  const lightBtn = document.getElementById('light');
  const matBtn = document.getElementById('mat');
  const note = document.getElementById('note');

  function update() {
    const isStd = knot.material === standard;
    lightBtn.textContent = 'Lights: ' + (lights.visible ? 'on' : 'off');
    lightBtn.setAttribute('aria-pressed', lights.visible);
    matBtn.textContent = 'Material: ' + (isStd ? 'Standard' : 'Basic');
    matBtn.setAttribute('aria-pressed', isStd);
    note.textContent = isStd && !lights.visible
      ? 'Black: MeshStandardMaterial with no light. Turn a light on.'
      : isStd ? 'Shaded: MeshStandardMaterial reacts to the lights.'
      : 'Flat: MeshBasicMaterial is not affected by lights.';
  }

  lightBtn.addEventListener('click', () => { lights.visible = !lights.visible; update(); });
  matBtn.addEventListener('click', () => {
    knot.material = knot.material === standard ? basic : standard;
    update();
  });
  update();

  // size the canvas to its box, not the window
  new ResizeObserver(() => {
    const w = view.clientWidth, h = view.clientHeight;
    renderer.setSize(w, h, false);
    camera.aspect = w / h;
    camera.updateProjectionMatrix();
  }).observe(view);

  renderer.setAnimationLoop((t) => {
    knot.rotation.y = t / 1500;
    renderer.render(scene, camera);
  });
</script>
</body>
</html>
Turn the lights off, then switch the material. Only the lit material goes black.
Material Needs a light? Looks like
MeshBasicMaterial No One flat colour, no shading
MeshNormalMaterial No Colours mapped from the surface direction
MeshStandardMaterial Yes Shaded, with roughness and metalness

For lit materials, an AmbientLight lights every side equally and a DirectionalLight gives the bright and dark faces. The three.js source notes that MeshBasicMaterial "is not affected by lights".

Size, sharpness and phones

The renderer's canvas has two sizes: how big it looks on the page, and how many pixels it draws. If either is wrong, the scene stretches or looks soft.

A wrong aspect ratio stretches the scene. A missing pixel ratio makes it soft on dense screens.
A wrong aspect ratio stretches the scene. A missing pixel ratio makes it soft on dense screens.
  • Call setPixelRatio(devicePixelRatio) on the renderer once, so phones with dense screens get a sharp picture.
  • When the space changes size, call setSize with the new width and height, set camera.aspect to width divided by height, then call camera.updateProjectionMatrix().
  • If the canvas fills a box instead of the window, watch the box with a ResizeObserver. The second and third examples do this.

Add the viewport meta tag too, or a phone lays the page out at desktop width.

A finished example: a viewer you can turn

Controls, loaders and effects live in the addons folder. The installation guide says addons need no separate install, only a separate import. Add a second entry to the import map for that folder, with the same version.

"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/"

Then import OrbitControls and hand it the camera and the canvas. Drag to turn, scroll or pinch to zoom.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>three.js viewer with OrbitControls</title>
<style>
  html, body { margin: 0; height: 100%; }
  body { display: flex; flex-direction: column; font-family: system-ui, sans-serif; background: #f4f5f7; }
  #view { flex: 1; min-height: 0; position: relative; }
  canvas { display: block; width: 100%; height: 100%; touch-action: none; }
  #hint {
    position: absolute; left: 10px; top: 8px; margin: 0;
    font-size: 12px; color: #4b5563; pointer-events: none;
  }
  .bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; padding: 10px 12px; background: #fff; }
  .sw { width: 30px; height: 30px; border-radius: 50%; border: 2px solid #fff; box-shadow: 0 0 0 1px #d1d5db; cursor: pointer; }
  .sw[aria-pressed="true"] { box-shadow: 0 0 0 2px #111827; }
  #reset { font: inherit; font-size: 14px; padding: 7px 12px; border: 1px solid #d1d5db; border-radius: 8px; background: #fff; cursor: pointer; margin-left: auto; }
</style>
<script type="importmap">
{
  "imports": {
    "three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js",
    "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/"
  }
}
</script>
</head>
<body>
<div id="view"><p id="hint">Drag to turn. Scroll or pinch to zoom.</p></div>
<div class="bar">
  <button class="sw" data-color="#2563eb" style="background:#2563eb" aria-label="Blue" aria-pressed="true"></button>
  <button class="sw" data-color="#e11d48" style="background:#e11d48" aria-label="Red" aria-pressed="false"></button>
  <button class="sw" data-color="#f59e0b" style="background:#f59e0b" aria-label="Amber" aria-pressed="false"></button>
  <button class="sw" data-color="#10b981" style="background:#10b981" aria-label="Green" aria-pressed="false"></button>
  <button id="reset">Reset view</button>
</div>

<script type="module">
  import * as THREE from 'three';
  import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

  const view = document.getElementById('view');
  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0xf8fafc);
  const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
  camera.position.set(3, 2.2, 4);

  const renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.setPixelRatio(devicePixelRatio);
  view.appendChild(renderer.domElement);

  // drag / wheel / pinch moves the camera around the target
  const controls = new OrbitControls(camera, renderer.domElement);
  controls.target.set(0, 0.6, 0);
  controls.enableDamping = true;
  controls.minDistance = 2.5;
  controls.maxDistance = 10;
  controls.update();
  controls.saveState();  // remembered for "Reset view"

  // a texture drawn in the page: no image file to load
  const c = document.createElement('canvas');
  c.width = c.height = 128;
  const g = c.getContext('2d');
  g.fillStyle = '#e2e8f0'; g.fillRect(0, 0, 128, 128);
  g.fillStyle = '#cbd5e1'; g.fillRect(0, 0, 64, 64); g.fillRect(64, 64, 64, 64);
  const tex = new THREE.CanvasTexture(c);
  tex.colorSpace = THREE.SRGBColorSpace;
  tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
  tex.repeat.set(4, 4);
  const floor = new THREE.Mesh(new THREE.PlaneGeometry(6, 6), new THREE.MeshStandardMaterial({ map: tex }));
  floor.rotation.x = -Math.PI / 2;
  scene.add(floor);

  // the object we look at
  const paint = new THREE.MeshStandardMaterial({ color: 0x2563eb, roughness: 0.35, metalness: 0.1 });
  const knot = new THREE.Mesh(new THREE.TorusKnotGeometry(0.5, 0.18, 160, 20), paint);
  knot.position.y = 0.9;
  const base = new THREE.Mesh(new THREE.CylinderGeometry(0.7, 0.8, 0.2, 48), new THREE.MeshStandardMaterial({ color: 0x334155 }));
  base.position.y = 0.1;
  scene.add(knot, base);

  scene.add(new THREE.HemisphereLight(0xffffff, 0x94a3b8, 1.2));
  const sun = new THREE.DirectionalLight(0xffffff, 2);
  sun.position.set(3, 5, 2);
  scene.add(sun);

  // colour buttons change the material, not the mesh
  document.querySelectorAll('.sw').forEach((btn) => {
    btn.addEventListener('click', () => {
      paint.color.set(btn.dataset.color);
      document.querySelectorAll('.sw').forEach((b) => b.setAttribute('aria-pressed', b === btn));
    });
  });
  document.getElementById('reset').addEventListener('click', () => controls.reset());

  new ResizeObserver(() => {
    const w = view.clientWidth, h = view.clientHeight;
    renderer.setSize(w, h, false);
    camera.aspect = w / h;
    camera.updateProjectionMatrix();
  }).observe(view);

  renderer.setAnimationLoop((t) => {
    knot.rotation.y = t / 2500;
    controls.update();  // needed every frame when damping is on
    renderer.render(scene, camera);
  });
</script>
</body>
</html>
OrbitControls from the addons folder, a floor texture drawn on a canvas, and colour buttons that change one material.
  • Same version for every file. The guide warns that mixing versions or CDNs can include duplicate code or break the page.
  • Damping needs an update call. With enableDamping on, call controls.update() in the draw loop.
  • No image files. The checkered floor is a CanvasTexture drawn by the page itself, so there is nothing extra to load.

Opening the file, and when you need a server

In our tests, every example here ran when opened straight from disk, because all of the code is inline and three.js comes from the CDN. That stops when you split your code into a local main.js file.

Loading a local module from a file:// address is blocked by the browser. MDN describes this as a CORS error and recommends testing through a server. The three.js guide also notes that some features do not work when the page is opened by double-click.

The guide's other route is npm and the Vite build tool. That is the right fit once a project grows into several files. It does not run inside a single HTML file:

npm install --save three
npm install --save-dev vite
npx vite          # local dev server
npx vite build    # output in dist/

For one page you want to send to someone, inline code plus the import map is enough. Self-contained HTML files covers that approach in general.

When it does not work

What you see Cause Fix
Failed to resolve module specifier "three" No import map, or it comes after the module script Add the import map in the head, first
Cannot use import statement outside a module The script tag has no type="module" Add type="module"
THREE is not defined An old tutorial's three.min.js, removed in r161 Use the import map and three.module.js
The same error names an addon path The map has no three/addons/ entry Add it with the same version
A black shape A lit material and no light Add lights, or use MeshBasicMaterial
An empty scene, no error The camera is inside the object, or render never runs Move the camera back and start the draw loop
A stretched shape camera.aspect not updated after a resize Set it, then updateProjectionMatrix()
Soft, blurry edges No pixel ratio set setPixelRatio(devicePixelRatio)
main.js blocked by CORS A local module file opened from disk Keep the code inline, or use a local server

For errors outside three.js, HTML JavaScript not working walks through the console checks.

A 3D scene is hard to show in a screenshot: the point is turning it yourself. An .html attachment may open as plain code on a phone, or not at all.

To send the working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, including three.js loaded from cdn.jsdelivr.net, so anyone with the link can turn the model without an account.

Images, models or data fetched from other sites are blocked there, so build shapes and textures in the page as the viewer does. If you change the code later, the same link shows the new version.

Questions people ask

Can I use three.js without npm or a build tool?

Yes. The official installation guide describes two routes: npm with a build tool such as Vite, or importing three.js from a CDN with an import map. Every example on this page uses the CDN route inside one HTML file.

Why does my old three.js tutorial say THREE is not defined?

Older tutorials load build/three.min.js with a plain script tag, which creates a global THREE. Those build files were removed in r161, so the request fails and THREE never exists. Use an import map that points at build/three.module.js and a script with type="module".

Why is my three.js object completely black?

MeshStandardMaterial and the other lit materials need a light in the scene. Without one the surface renders black. Add an AmbientLight and a DirectionalLight, or switch to MeshBasicMaterial, which is not affected by lights.

Does a three.js page work when I double-click the HTML file?

When all your code sits inline in the HTML file and three.js comes from a CDN, it did in our tests. A separate main.js loaded as a module from a file:// address is blocked by the browser, and the three.js installation guide recommends a local server because some features do not work from a double-clicked file.

Is three.js free to use?

Yes. three.js is published under the MIT licence, which allows use in personal and commercial projects as long as the copyright and licence notice are kept with the code.

Keep reading