Guides · 6 min read · Updated
How do you add three.js to a website?
Put one fixed canvas behind your page content, load three.js as a module (an import map for plain HTML, or npm in React), and drive a single render loop that handles resize, caps the pixel ratio and pauses when the canvas is not visible. Keep your text and buttons as normal HTML on top, so the page stays fast, accessible and easy to change.
The shape of a good setup
The setup that holds up in production is simple: one canvas fixed behind the whole page, one WebGLRenderer, and your real content as ordinary HTML layered above it. The canvas is decoration and atmosphere. The headline, navigation and forms stay in the DOM, where search engines read them, screen readers announce them and links work.
One canvas matters more than it sounds. Every WebGL context costs graphics memory, and browsers limit how many can be alive at once. A page with a separate canvas in each section can lose contexts on a phone. A single fixed canvas that you move a camera through, or whose objects you fade in and out, is cheaper and lets scenes blend into each other.
Plain HTML with an import map
You do not need a build step to start. An import map is a script of type importmap that tells the browser where a bare name like three lives, so ordinary import statements work. It must appear before any module script that uses it.
Pin an exact version in the URL so an update never changes your page, and keep the core library and its addons on the same version. The version below is an example; use the current release when you build.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>three.js background</title>
<style>
html { background: #05060a; color: #f4f4f5; }
body { margin: 0; background: transparent; font-family: system-ui, sans-serif; }
#bg { position: fixed; inset: 0; width: 100%; height: 100%; z-index: -1; pointer-events: none; }
main { position: relative; min-height: 300vh; padding: 20vh 8vw; }
</style>
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
} }
</script>
</head>
<body>
<canvas id="bg" aria-hidden="true"></canvas>
<main><h1>Real HTML on top of the canvas</h1></main>
<script type="module" src="./scene.js"></script>
</body>
</html>The root element carries the page background and the body stays transparent. A fixed element with a negative z-index paints above the root background but below the body content, so the canvas shows through and your text sits on top.
The scene, the render loop and resize
The module below creates a renderer on your canvas, one camera and one mesh, and starts a loop. Three details are worth copying exactly. It reads time from the loop instead of counting frames, so speed does not change with the display. It caps the pixel ratio at 2. And it resizes the drawing buffer from the canvas's own size, with setSize(w, h, false) so three.js does not overwrite your CSS.
import * as THREE from "three";
const canvas = document.getElementById("bg");
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.z = 6;
const knot = new THREE.Mesh(
new THREE.TorusKnotGeometry(1.2, 0.35, 160, 24),
new THREE.MeshNormalMaterial()
);
scene.add(knot);
function resize() {
const w = canvas.clientWidth;
const h = canvas.clientHeight;
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
new ResizeObserver(resize).observe(canvas);
resize();
const clock = new THREE.Clock();
renderer.setAnimationLoop(() => {
const t = clock.getElapsedTime();
knot.rotation.set(t * 0.2, t * 0.3, 0);
renderer.render(scene, camera);
});setAnimationLoop runs your function each frame using the browser's frame scheduling, and passing null stops it. The browser's requestAnimationFrame underneath is generally paused in background tabs, which saves battery for free.
Pick materials by what they cost
The material decides how much work the GPU does for every pixel the object covers. A basic or normal material is nearly free. A standard physical material with lights, shadows and environment reflections costs much more, and a physical material with transmission renders the scene behind it a second time. On a decorative background, start with the cheap option and add one expensive effect only if the design needs it.
- Prefer unlit or simply lit materials for backgrounds, and bake lighting into textures where you can.
- Use one directional light and an environment map before you reach for many point lights.
- Turn shadows off unless a shadow is the point of the scene. Shadow maps add extra render passes.
- Reuse geometry and materials across meshes, and use instancing for repeated objects.
A custom shader material can be cheaper than a standard one, because it does only what you wrote. It also takes more care. Start from a working example, change one thing at a time, and keep the fragment work small.
Cap the pixel ratio
A phone with a device pixel ratio of 3 asks your GPU to shade nine times as many pixels as a ratio of 1. That is the most common reason a good-looking scene runs badly. Capping at 2 is a safe default, and a cap of 1.5 is often indistinguishable on a moving background while cutting fragment work by almost half. If your scene has a heavy fragment shader, render it at a lower ratio than the rest of the page.
| Device pixel ratio | Pixels shaded vs ratio 1 | With a cap of 2 | With a cap of 1.5 |
|---|---|---|---|
| 1 | 1 times | 1 times | 1 times |
| 2 | 4 times | 4 times | 2.25 times |
| 3 | 9 times | 4 times | 2.25 times |
The numbers come from squaring the ratio, because the width and the height both grow. Treat them as a way to reason about cost, not as a benchmark.
Pause when nobody can see it
A background that keeps rendering behind a full-screen video or a long text section wastes battery and heats a phone. Pause the loop when the tab is hidden, and when a section that needs the canvas is out of view. IntersectionObserver reports visibility without polling.
let visible = true;
let tabShown = !document.hidden;
function update() {
const run = visible && tabShown;
renderer.setAnimationLoop(run ? frame : null);
}
function frame() {
const t = clock.getElapsedTime();
knot.rotation.set(t * 0.2, t * 0.3, 0);
renderer.render(scene, camera);
}
new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
update();
}).observe(document.querySelector("#scene-section"));
document.addEventListener("visibilitychange", () => {
tabShown = !document.hidden;
update();
});
update();For a fixed full-page canvas there is no section to watch, so the tab-visibility check is the part to keep. Use the observer when the canvas lives in one part of the page, such as a hero that scrolls away.
The same thing in React
In React, wrap the canvas in a component that creates the renderer in an effect and cleans everything up when it unmounts. three.js touches window and the canvas, so it belongs inside useEffect, and in Next.js the file needs "use client". If you prefer a declarative style, React Three Fiber and Drei appear in the stack of hundreds of AnimatedWebs templates; the plain component below shows what they do for you.
"use client";
import { useEffect, useRef } from "react";
import * as THREE from "three";
export function BackgroundCanvas() {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = ref.current;
if (!canvas) return;
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.z = 6;
const geometry = new THREE.IcosahedronGeometry(1.4, 3);
const material = new THREE.MeshNormalMaterial({ wireframe: true });
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
const resize = () => {
const w = canvas.clientWidth;
const h = canvas.clientHeight;
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
};
const ro = new ResizeObserver(resize);
ro.observe(canvas);
resize();
const clock = new THREE.Clock();
const frame = () => {
mesh.rotation.y = clock.getElapsedTime() * 0.25;
renderer.render(scene, camera);
};
const onVisibility = () => renderer.setAnimationLoop(document.hidden ? null : frame);
document.addEventListener("visibilitychange", onVisibility);
onVisibility();
return () => {
document.removeEventListener("visibilitychange", onVisibility);
renderer.setAnimationLoop(null);
ro.disconnect();
geometry.dispose();
material.dispose();
renderer.dispose();
};
}, []);
return <canvas ref={ref} aria-hidden className="pointer-events-none fixed inset-0 -z-10 h-full w-full" />;
}The cleanup function is the part people skip. In development React runs effects twice, and without disposal each run leaves a renderer and a WebGL context behind. Dispose the geometry, materials and textures you created, then the renderer.
Connect the scene to scroll
The usual next step is to let scroll drive the camera or object positions. Keep the scroll handler trivial: store a number, and read that number inside the render loop. Smooth it first with the easing from the scroll animation guide so the camera glides rather than jumps.
let target = 0;
let eased = 0;
addEventListener("scroll", () => {
const max = document.documentElement.scrollHeight - innerHeight;
target = max > 0 ? scrollY / max : 0;
}, { passive: true });
// inside the render loop
eased += (target - eased) * 0.06;
camera.position.y = -eased * 8;
camera.lookAt(0, camera.position.y, 0);Loading models and textures without blocking the page
A scene made only of code, like the ones above, costs almost nothing to download. The moment you add a model or a texture, loading becomes your main performance question. Keep the page usable while assets arrive: show the HTML and a poster first, create the renderer, and fade the canvas in when the first frame is ready.
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
const loader = new GLTFLoader();
loader.load("/models/product.glb", (gltf) => {
scene.add(gltf.scene);
document.documentElement.classList.add("scene-ready"); // CSS fades the canvas in
});Compress the model before it ships and keep it to a size a phone can fetch quickly. Textures are usually the bigger part: a 4096 pixel texture takes far more graphics memory than a 1024 pixel one, whatever its file size on disk. Match the texture size to how large the object appears on screen.
Handle a lost context and a missing GPU
Browsers can take a WebGL context away, for example when the device is under memory pressure, and some visitors have no usable GPU at all. Treat both as normal states. Listen for webglcontextlost on the canvas, stop the loop, and either wait for webglcontextrestored or show the still poster. Before creating the renderer, you can ask for a context with failIfMajorPerformanceCaveat set to true, and show the fallback if the browser refuses.
canvas.addEventListener("webglcontextlost", (e) => {
e.preventDefault(); // lets the browser restore it later
renderer.setAnimationLoop(null);
});
canvas.addEventListener("webglcontextrestored", () => {
renderer.setAnimationLoop(frame);
});The page should read well with the canvas missing. If your headline, copy and buttons are plain HTML, the worst case is a plain background, which is a fine outcome.
Mistakes that cost the most
- Creating a new renderer per component. Share one, and move content through it.
- Allocating inside the loop. Creating vectors, geometries or materials every frame causes garbage collection pauses. Create once, reuse.
- Shipping a 20 MB model. Compress textures, simplify meshes, and load heavy assets after first paint.
- Forgetting that real devices differ. Test on a phone, and read the phone performance guide before launch.
- Letting the canvas steal clicks. Use
pointer-events: noneon a decorative canvas. - Leaving the scene on for visitors who asked for reduced motion. Render one still frame instead of looping.





