Sync HTML Elements to 3D Positions with CSS2DRenderer
This article explains how to use the Three.js
CSS2DRenderer to align and synchronize 2D HTML elements
with 3D positions in a WebGL scene. You will learn how to set up the
renderer, bind HTML elements to 3D Object3D instances, and apply
localized CSS transforms—such as scaling, rotation, or custom
offsets—without interfering with the renderer’s automatic screen-space
positioning.
The Challenge with CSS2DRenderer Transforms
The CSS2DRenderer in Three.js works by projecting 3D
coordinates onto a 2D viewport and updating the target HTML element’s
inline transform style (specifically using
translate(-50%, -50%) translate3d(x, y, z)).
Because the renderer continuously overwrites the element’s
transform property on every frame, you cannot
directly apply custom CSS transforms (like rotate() or
scale()) to the primary element managed by the
CSS2DObject. Doing so will cause your styles to be
immediately overwritten.
The Solution: Nested HTML Structure
To apply localized CSS transforms to a synchronized HTML element, you must use a nested HTML structure:
- The Wrapper Element: This is the parent element
passed to the
CSS2DObject. TheCSS2DRenderermanages its position. - The Content Element: This is a child element nested inside the wrapper. Because the renderer does not touch this element, you can safely apply any localized CSS transforms, transitions, or animations to it.
Step 1: Define the HTML and CSS
Create a CSS class for your content element. Since it is nested, any transforms applied to it will be relative to the synchronized 3D anchor position.
/* The wrapper managed by CSS2DRenderer */
.label-wrapper {
pointer-events: none; /* Let pointer events pass through to WebGL if needed */
}
/* The content element where you apply localized transforms */
.label-content {
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 8px 12px;
border-radius: 4px;
font-family: sans-serif;
/* Apply localized transforms here */
transform: translate(10px, -20px) rotate(-5deg) scale(1.1);
transform-origin: center center;
transition: transform 0.3s ease;
}
/* Example: Localized transform on hover */
.label-content:hover {
transform: translate(10px, -20px) rotate(0deg) scale(1.2);
background: #ff0055;
}Step 2: Initialize CSS2DRenderer in JavaScript
Set up the CSS2DRenderer alongside your standard WebGL
WebGLRenderer.
import * as THREE from 'three';
import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js';
// Initialize WebGL Renderer
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// Initialize CSS2D Renderer
const labelRenderer = new CSS2DRenderer();
labelRenderer.setSize(window.innerWidth, window.innerHeight);
labelRenderer.domElement.style.position = 'absolute';
labelRenderer.domElement.style.top = '0px';
labelRenderer.domElement.style.pointerEvents = 'none'; // Allows WebGL controls to work
document.body.appendChild(labelRenderer.domElement);Step 3: Create the Nested DOM Element and CSS2DObject
Construct the nested element structure in your JavaScript code, assign the styles, and bind it to a 3D object in your scene.
// 1. Create the wrapper container
const wrapper = document.createElement('div');
wrapper.className = 'label-wrapper';
// 2. Create the content container (where custom transforms live)
const content = document.createElement('div');
content.className = 'label-content';
content.textContent = '3D Anchor Label';
wrapper.appendChild(content);
// 3. Create the CSS2DObject
const labelObject = new CSS2DObject(wrapper);
// 4. Position the object in 3D space (or add it as a child of a 3D Mesh)
const sphereGeometry = new THREE.SphereGeometry(1, 32, 32);
const sphereMaterial = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
const sphere = new THREE.Mesh(sphereGeometry, sphereMaterial);
scene.add(sphere);
// Offset the label slightly above the sphere
labelObject.position.set(0, 1.5, 0);
sphere.add(labelObject);Step 4: Update the Render Loop
In your animation loop, render both the WebGL scene and the CSS2D scene to keep the positions synchronized.
function animate() {
requestAnimationFrame(animate);
// Rotate the mesh to show tracking
sphere.rotation.y += 0.01;
// Render WebGL
renderer.render(scene, camera);
// Render CSS2D overlay
labelRenderer.render(scene, camera);
}
animate();Using this nested element approach ensures that the coordinate translation handled by Three.js and your localized presentation transforms (like rotation, scaling, and hover effects) remain completely separated and do not conflict.