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:

  1. The Wrapper Element: This is the parent element passed to the CSS2DObject. The CSS2DRenderer manages its position.
  2. 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.