Embed DOM Elements in Three.js with CSS3DRenderer

Integrating standard HTML elements like videos, forms, and interactive buttons into a 3D WebGL scene can be challenging because standard WebGL renderers draw on a flat canvas. This article explains how to use Three.js’s CSS3DRenderer to transform standard DOM elements into 3D space, mapping them alongside your WebGL objects while preserving their native HTML interactivity and styling.

Understanding the CSS3DRenderer

While the standard WebGLRenderer projects 3D geometry onto a 2D canvas using shaders, the CSS3DRenderer utilizes CSS3 3D transforms (transform-style: preserve-3d) to translate, rotate, and scale HTML elements. By syncing the CSS3D camera with your WebGL camera, DOM elements appear perfectly aligned within the 3D space, allowing users to select text, click buttons, and interact with inputs as if they were on a flat web page.

Step 1: Import the Necessary Modules

To get started, you need to import the standard Three.js library along with CSS3DRenderer and CSS3DObject from the examples folder.

import * as THREE from 'three';
import { CSS3DRenderer, CSS3DObject } from 'three/examples/jsm/renderers/CSS3DRenderer.js';

Step 2: Set Up the HTML and CSS Renderers

For a fully integrated scene where WebGL models and HTML elements coexist, you must create and layer two renderers: the WebGLRenderer and the CSS3DRenderer.

// Set up scene, camera
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.z = 500;

// Set up CSS3D Renderer
const cssRenderer = new CSS3DRenderer();
cssRenderer.setSize(window.innerWidth, window.innerHeight);
cssRenderer.domElement.style.position = 'absolute';
cssRenderer.domElement.style.top = '0';
cssRenderer.domElement.style.pointerEvents = 'none'; // Allows clicks to pass through if necessary
document.body.appendChild(cssRenderer.domElement);

// Set up WebGL Renderer
const webglRenderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
webglRenderer.setSize(window.innerWidth, window.innerHeight);
webglRenderer.domElement.style.position = 'absolute';
webglRenderer.domElement.style.top = '0';
webglRenderer.domElement.style.zIndex = '1'; 
document.body.appendChild(webglRenderer.domElement);

To ensure users can click on the HTML elements, you may need to swap the zIndex or manage the CSS pointer-events property so that the interactive layer sits correctly in the pointer hierarchy.

Step 3: Create and Embed the DOM Element

Next, define the HTML element in JavaScript, apply standard CSS styles, wrap it in a CSS3DObject, and add it to your scene.

// Create the DOM element
const element = document.createElement('div');
element.style.width = '300px';
element.style.height = '200px';
element.style.background = '#007acc';
element.style.color = '#white';
element.style.padding = '20px';
element.style.boxSizing = 'border-box';
element.innerHTML = `
  <h3>Interactive 3D Card</h3>
  <p>This is a native HTML element inside a Three.js scene.</p>
  <button id="myButton" style="padding: 10px; cursor: pointer;">Click Me</button>
`;

// Add interactivity directly to the element
element.querySelector('#myButton').addEventListener('click', () => {
  alert('Button clicked inside 3D space!');
});

// Convert DOM element to a 3D Object
const cssObject = new CSS3DObject(element);
cssObject.position.set(0, 0, 0);
scene.add(cssObject);

Step 4: The Animation Loop

To keep the 3D transforms synchronized, you must update both the WebGL renderer and the CSS3D renderer inside your requestAnimationFrame loop.

function animate() {
  requestAnimationFrame(animate);

  // Optional: Rotate the CSS3D object to demonstrate 3D spatial positioning
  cssObject.rotation.y += 0.01;

  // Render both scenes
  webglRenderer.render(scene, camera);
  cssRenderer.render(scene, camera);
}

animate();

Handling Resize Events

To prevent distortion when resizing the browser window, update the aspect ratio of the camera and reset the sizes for both renderers.

window.addEventListener('resize', () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();

  webglRenderer.setSize(window.innerWidth, window.innerHeight);
  cssRenderer.setSize(window.innerWidth, window.innerHeight);
});