Why HTML in a 3D scene?
Three.js renders to a <canvas>. Everything inside it, from text or icons to tooltips, is pixels. You can't select canvas text, you can't style it with CSS, and screen readers can't see it. For many UI needs (labels, health bars, tooltips, debug info), plain HTML is better. The challenge is making that HTML follow a point in 3D space as the camera moves.
There are two approaches. You can use Three.js's built-in CSS2DRenderer and CSS3DRenderer, which manage DOM elements for you. Or you can do the math yourself with Vector3.project() and position elements manually. Each are useful in different situations, so let's try taking a look at all of them one by one.
CSS2DRenderer
CSS2DRenderer creates a transparent DOM layer on top of your WebGL canvas. You wrap any HTML element in a CSS2DObject, add it to a 3D object in your scene, and the renderer keeps the HTML positioned correctly as the camera moves. The HTML always faces the screen, meaning that it doesn't rotate with the object. This is what you want for labels, tooltips, and HUD-style elements.
The labels below follow the rotating shapes. Try to notice how they always face you regardless of the shape's orientation:
import { CSS2DRenderer, CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js';
// Create the CSS2D renderer, sized to match your WebGL renderer
const labelRenderer = new CSS2DRenderer();
labelRenderer.setSize(window.innerWidth, window.innerHeight);
labelRenderer.domElement.style.position = 'absolute';
labelRenderer.domElement.style.top = '0';
labelRenderer.domElement.style.pointerEvents = 'none';
document.body.appendChild(labelRenderer.domElement);
// Create an HTML label
const div = document.createElement('div');
div.textContent = 'Cube';
div.style.color = 'white';
div.style.fontSize = '14px';
div.style.padding = '4px 8px';
div.style.background = 'rgba(0, 0, 0, 0.6)';
div.style.borderRadius = '4px';
// Wrap it in a CSS2DObject and attach to a mesh
const label = new CSS2DObject(div);
label.position.set(0, 1, 0); // offset above the mesh
cube.add(label);
// In your animation loop, render both
function animate() {
renderer.render(scene, camera);
labelRenderer.render(scene, camera);
requestAnimationFrame(animate);
}A few things to notice:
- The label renderer's DOM element must be positioned absolute and layered on top of the canvas. It's a transparent overlay.
- Set
pointerEvents = 'none'on the overlay so clicks pass through to the canvas (or to specific elements if you want clickable labels). - The label is added as a child of the mesh, so its position is relative to the mesh.
(0, 1, 0)means one unit above the mesh's origin. - You must call
labelRenderer.render()every frame alongside your WebGL render call.
Styling labels with CSS
Because these are real DOM elements, you can style them however you want. CSS classes, transitions, hover effects, even Vue or React components. Anything that works in HTML works here!
const div = document.createElement('div');
div.className = 'scene-label-3d';
div.innerHTML = '<strong>Iron Ore</strong><br><span>Tier 2 Resource</span>';
// In your CSS:
// .scene-label-3d {
// font-family: sans-serif;
// color: white;
// background: rgba(0, 0, 0, 0.7);
// padding: 6px 10px;
// border-radius: 6px;
// border: 1px solid rgba(255, 255, 255, 0.2);
// backdrop-filter: blur(4px);
// transition: opacity 0.2s;
// }CSS3DRenderer
While CSS2DRenderer keeps labels facing the screen, CSS3DRenderer transforms HTML elements to match 3D orientation. The elements rotate, scale with perspective, and can be occluded (with some effort). Think of it as embedding a webpage into your 3D scene. It's just like a monitor screen or a billboard that exists as a physical object in the world.
The panels below are HTML elements positioned in 3D space using CSS3DRenderer. Unlike CSS2DRenderer labels, they scale with perspective and exist as surfaces in the scene:
import { CSS3DRenderer, CSS3DObject } from 'three/addons/renderers/CSS3DRenderer.js';
const css3dRenderer = new CSS3DRenderer();
css3dRenderer.setSize(window.innerWidth, window.innerHeight);
css3dRenderer.domElement.style.position = 'absolute';
css3dRenderer.domElement.style.top = '0';
document.body.appendChild(css3dRenderer.domElement);
// Create an HTML element that will exist in 3D space
const panel = document.createElement('div');
panel.style.width = '200px';
panel.style.height = '120px';
panel.style.background = 'rgba(0, 40, 80, 0.9)';
panel.style.color = 'white';
panel.style.padding = '16px';
panel.style.borderRadius = '8px';
panel.style.fontSize = '14px';
panel.innerHTML = '<h3>Status Panel</h3><p>Health: 100%</p>';
// Wrap in CSS3DObject, which gives it a 3D transform
const panelObject = new CSS3DObject(panel);
panelObject.position.set(0, 1.5, 0);
panelObject.scale.set(0.01, 0.01, 0.01); // scale down to scene units
scene.add(panelObject);The scale is important. CSS3DObject maps pixel dimensions to world units. A 200px-wide div would be 200 units wide in your scene without scaling. Setting the scale to 0.01 makes it 2 units wide, which is a reasonable size relative to typical meshes.
Manual positioning
Sometimes you don't want the overhead of CSS2DRenderer, or you need more control over when and how labels appear. The manual approach uses Vector3.project() to convert a 3D world position into 2D screen coordinates, then positions a regular DOM element at those coordinates.
In the demo below, each object has a label that's positioned using manual projection. The labels show the object's live world-space coordinates:
function worldToScreen(position, camera, canvas) {
// Clone so we don't mutate the original
const pos = position.clone();
// Project from 3D world space to NDC (-1 to +1)
pos.project(camera);
// Convert NDC to pixel coordinates
const rect = canvas.getBoundingClientRect();
const x = (pos.x * 0.5 + 0.5) * rect.width;
const y = (-pos.y * 0.5 + 0.5) * rect.height;
// pos.z tells you if the point is behind the camera
const isBehindCamera = pos.z > 1;
return { x, y, isBehindCamera };
}
// In your animation loop:
function animate() {
renderer.render(scene, camera);
const screen = worldToScreen(mesh.position, camera, renderer.domElement);
if (screen.isBehindCamera) {
label.style.display = 'none';
} else {
label.style.display = 'block';
label.style.left = screen.x + 'px';
label.style.top = screen.y + 'px';
}
requestAnimationFrame(animate);
}The projection math works in two steps. First, Vector3.project(camera) transforms the world position into normalized device coordinates (NDC), where the visible area is -1 to +1 on both axes. Then you convert NDC to pixel coordinates relative to the canvas.
The z component after projection tells you whether the point is in front of or behind the camera. If it's greater than 1, the point is behind the camera and you should hide the label. Without this check, labels would appear mirrored on the opposite side of the screen when objects go behind the camera.