Pontus Horn
Regular human web developer

Build 3D web components with Three.js

Boxes of various sizes and colors floating around in 3D space

Table of contents


These days it’s easier than ever to use 3D graphics on the web. Libraries like Three.js can do the heavy lifting for you, and for simple visualisations and static graphics, that might be all you need. However, if you want to build more complex scenes you may prefer a declarative interface to help keep the UI in sync with your state, and as an abstraction that helps readability and ease of use.

Declarative 3D libraries

There are a few libraries that provide declarative APIs on top of Three.js: @react-three/fiber makes objects from Three.js available as React components. Threlte is an equivalent library for Svelte that’s been gaining traction. Another prominent example, built in web components, is A-Frame. You may be inclined to just pick a library like this and run with it, and in many cases that’s exactly the right choice - these are great, robust libraries that have earned their popularity!

But before you head off to npm…

I’d like to pull back the curtain a little bit in this post and show that the basic mechanics of building this yourself are not rocket science. Even if you end up using a library, understanding something of what it’s doing for you can be a superpower!

Building a declarative wrapper of our own is not without its benefits, either:

  • We will understand an important part of our app much better, which can make debugging easier
  • It lets us add only the code we need, avoiding unnecessary complexity and code shipped to users
  • We can adapt the API exactly to our liking and add any features we need.

By using web components, we’ll open up some interesting and powerful possibilities - more on that later. But most of this should be fairly transferable to your component framework of choice.

This post doesn’t require prior knowledge of Three.js or web components, but it’s not intended as a tutorial to learn them either - there are plenty of good resources for that already. I’ve listed some in the Further reading section.

About Three.js

Three.js provides an object-oriented, imperative API that abstracts away a lot of hairy details around WebGL and 3D rendering. I highly recommend starting out with a library like this or Babylon.js if you’re not an expert and want to be productive.

Check out the Three.js docs if you’d like to explore it on your own.

Using Three.js through web components

3D rendering might not be the most intuitive use case for web components, but the pairing has a lot going for it:

  • Declarative! As popular libraries have shown, rendering your 3D scenes in a declarative component tree makes a lot of sense, from both an authoring and readability perspective. This benefit applies when using component frameworks as well.
  • Platform integration! This is the superpower of web components. When you have a real HTML <cool-bird> element that represents your cool 3D bird model, a lot of web APIs become available to you. You can find all cool birds on the page using document.querySelectorAll('cool-bird'), dispatch a custom chirp event on a bird and have it bubble up the DOM, have it participate in the accessibility tree in various ways, and much more. Component frameworks don’t necessarily stop you from doing such things with the rendered output, but by their design they tend to favour more framework-flavoured patterns over working directly with the DOM and dispatching events on it.
  • You already have great dev tools installed for it! This is kind of “just” a happy side effect of the two previous points, but it’s actually so cool that I have to give it its own point. By expressing your 3D scene in custom elements, you can explore its structure right there in the browser dev tools, in virtually any browser, without installing any hastily constructed, semi-maintained extensions. You can even live edit your 3D scene just by tweaking attributes and using the tools that your regular old Elements inspector gives you!

A video showing me using the browser dev tools’ Elements inspector to add, rename, move, and transform spinning cubes rendered by web components.

Getting something on the screen

Let’s get into it! As mentioned before, the essentials of a basic setup are:

  • A scene that contains the objects and their hierarchy
  • A camera that defines the view perspective
  • A renderer that connects to a <canvas> and draws the scene to it through the camera
  • We need to add lights to the scene so we can see our objects
  • … and of course, we need to add those objects to the scene as well.

For this example, we’ll represent that through this HTML structure:

<canvas>
	<!--
	three-world sets up the scene, camera, renderer, and lights
	-->
	<three-world>
		<!--
	  three-box is a placeholder for any objects we want to show
		-->
		<three-box></three-box>
	</three-world>
</canvas>

This means <three-world> will absorb most of the complexity around rendering logic. This might raise some red flags about separating concerns - there are certainly multiple responsibilities shoved into one element here. So shouldn’t we separate them from the get-go?

My take is that if you’re making a library or if you know it will be used in multiple places, it totally makes sense to split up the objects into different elements for composability and reusability. But if you’re creating this for a single app or site, there’s absolutely no harm in keeping the setup in a single place until you start hitting on issues because of it.

The curse and the blessing of building it yourself is that it’s up to you to choose what works best. At the very least, you have the opportunity to iterate on what you choose.

But enough about that. Let’s start setting up by registering our first custom element.

If you wish to make an apple pie from scratch…

three-world.js
class ThreeWorld extends HTMLElement {
	constructor() {
		super();
		console.log("I'm gonna be 3D when I grow up!");
	}
}
 
window.customElements.define('three-world', ThreeWorld);

If you’re used to working with custom elements, this should be familiar so far. We create a subclass of HTMLElement and add it to the global custom elements registry. At this point, if we use the element in our page and run this script, we should get a message in the console to prove it’s being registered correctly.

index.html
<!doctype html>
<html>
	<head>
		<script src="three-world.js" type="module"></script>
	</head>
	<body>
		<canvas width="800" height="600">
			<three-world></three-world>
		</canvas>
	</body>
</html>
> I’m gonna be 3D when I grow up!

The message from the ThreeWorld constructor is printed to the console.

Creating a scene

We can initialise the scene itself in the constructor. I’ve imported Three.js straight from a CDN URL here, but you could also install it locally, or simplify the import statement with an import map if you prefer. See the Three.js Installation page for more information.

three-world.js
import * as THREE from 'https://unpkg.com/[email protected]/build/three.module.min.js';
 
class ThreeWorld extends HTMLElement {
	constructor() {
		super();
		this.scene = new THREE.Scene();
	}
}

Lights…

The scene needs to have some lights, or we won’t be able to see most types of objects. One light that simulates sunlight and one for ambient lights tends to be a good starting point:

three-world.js / ThreeWorld
class ThreeWorld extends HTMLElement {
	constructor() {
		super();
		this.scene = new THREE.Scene();
		this.#setupLights();
	}
 
	#setupLights() {
		// The "sun" provides strong light from a single direction
		const sun = new THREE.DirectionalLight(0xffffee);
		sun.position.set(-100, 200, 150);
		this.scene.add(sun);
 
		// Ambient light is softer and comes from all directions
		const ambient = new THREE.AmbientLight(0x404050, 5);
		this.scene.add(ambient);
	}
}

… Camera…

We also need a camera to define the viewer perspective. The camera needs access to the canvas, so we’ll set it up in the connectedCallback lifecycle method instead of the constructor:

three-world.js
class ThreeWorld extends HTMLElement {
	// constructor() { ... }
 
	connectedCallback() {
		this.#setupCamera();
	}
 
	/** Convenience getter for the current canvas */
	get canvas() {
		const canvas = this.closest('canvas');
		if (!canvas) {
			throw new Error('No ancestor canvas found');
		}
 
		return canvas;
	}
 
	// #setupLights() { ... }
 
	#setupCamera() {
		const { canvas } = this;
		this.camera = new THREE.PerspectiveCamera(
			45, // Field of view in degrees
			canvas.width / canvas.height, // Aspect ratio
			0.01, // Near plane
			10, // Far plane
		);
 
		// View the scene from an angle
		this.camera.position.set(3, 3, 3);
		this.camera.lookAt(0, 0, 0);
	}
}

Don’t worry too much about all the options right now if it looks daunting. The provided setup are decent defaults for our use case.

Note that we find the canvas element simply by traversing up the DOM. The DOM structure we used makes this easy: By nesting the world element in the canvas, we have a trivial and unambiguous way to find the correct canvas even if there are multiple canvases on the page.

… Action!

A renderer is needed to actually draw the results to the canvas. It also needs access to the canvas.

three-world.js
class ThreeWorld extends HTMLElement {
	// constructor() { ... }
 
	connectedCallback() {
		this.#setupCamera();
		this.#setupRenderer();
	}
 
	// get canvas() { ... }
	// #setupLights() { ... }
	// #setupCamera() { ... }
 
	#setupRenderer() {
		const { canvas } = this;
		const renderer = new THREE.WebGLRenderer({
			antialias: true,
			canvas,
		});
		renderer.setSize(canvas.width, canvas.height);
		renderer.setPixelRatio(window.devicePixelRatio);
 
		// Re-render the scene on every frame
		renderer.setAnimationLoop(() => {
			renderer.render(this.scene, this.camera);
		});
	}
}

Here we’ve set up an animation loop that renders the scene to the canvas on every frame. If your scene is static, you don’t need to keep rendering it over and over like this, but I’m doing so here as it will simplify things later on.

All the details of this setup are not that relevant to go into for the purposes of this example. If you do want to learn more or tweak the setup, you can find some explanation of the camera setup in the Three.js Fundamentals manual page, and the renderer setup in the WebGLRenderer reference page.

If we reload the page now, we should see… a black canvas. Not very exciting, but since it was transparent before, at least it means we’re drawing to it! Next, let’s try and make it more interesting.

Adding a box

It’s no wonder that we’re only seeing black when we haven’t added any physical objects yet. Let’s go for a box to keep it simple. For this, we’ll make another custom element to represent a simple object.

three-box.js
import * as THREE from 'https://unpkg.com/[email protected]/build/three.module.min.js';
 
class ThreeBox extends HTMLElement {
	constructor() {
		super();
 
		// Initialise a box and store it on the instance
		this.box = new THREE.Mesh(
			new THREE.BoxGeometry(0.5, 0.5, 0.5),
			new THREE.MeshStandardMaterial({
				color: 'cornflowerblue',
			}),
		);
	}
}
 
window.customElements.define('three-box', ThreeBox);

Here we initialise a mesh in the element’s constructor and store it on the element so we can reference it later. Before we can see our object though, we need to add it to the scene:

three-box.js
class ThreeBox extends HTMLElement {
	// constructor() { ... }
 
	/**
	 * Add the box to the closest `<three-world>` when
	 * inserted into the DOM
	 */
	connectedCallback() {
		this.scene.add(this.box);
	}
 
	/**
	 * Remove the box from the scene when removed from
	 * the DOM
	 */
	disconnectedCallback() {
		this.scene.remove(this.box);
	}
 
	/** Convenience getter for the current scene */
	get scene() {
		const world = this.closest('three-world');
		if (!world) {
			throw new Error('No ancestor three-world found');
		}
 
		return world.scene;
	}
}

Note again how natural it feels to find contextual data by traversing the DOM. Like built-in React context with simpler syntax and fewer rules and footguns. In this case we’re accessing the <three-world> element we set up and accessing the scene through it.

Let’s not forget to load and add our box element in the page:

index.html
<head>
	<script src="three-world.js" type="module"></script>
	<script src="three-box.js" type="module"></script>
</head>
<body>
	<canvas width="800" height="600">
		<three-world>
			<three-box></three-box>
		</three-world>
	</canvas>
</body>

If we load the page now, we should see something like this:

A blue 3D box viewed from an angle, lit from the top left

Providing alternative content

It’s easy to get caught up in the visuals when working with 3D, but this is a good time to take a step back and consider how to present this content in an alternative way to allow anyone to access it. Chances are you’re trying to convey something with your 3D visualisation. If a visitor is using a screen reader, then just like with images, we should provide a textual alternative that conveys the same information.

Disclaimer

I’m an accessibility enthusiast, but I wouldn’t call myself an expert, nor am I a screen reader user myself. Furthermore, I’m only starting to explore this intersection of 3D web content and accessibility, and can’t guarantee the techniques below will fit your use case. If you can, please consult with an accessibility expert and test with affected users to validate your approach.

On canvas fallback content

I used to think that content that you put inside a canvas element was only used as a fallback when <canvas> is not supported. I was surprised to learn that it’s exposed to the accessibility tree even if the canvas is active and rendering. This can be very useful for presenting rich alternative content for assistive tech. In my tests, I’ve found that content inside the canvas is also presented visually when JavaScript is disabled, which is a nice bonus.

Using custom elements to represent our objects gives us a leg up as we can compose alternative content comfortably with our visual content. The alternative content for a scene built with plain Three.js would typically live separately from the visual scene code, making it easy to neglect one when updating the other. With custom elements, we can colocate the content like so:

<canvas>
	<three-world>
		<cool-bird>
			A cool-looking bird wearing sunglasses and a fedora.
		</cool-bird>
	</three-world>
</canvas>

Neat! Now when you add or remove objects from the scene, it will be harder to forget to update the alternative content.

Don’t forget semantics!

This syntax in itself may work for some use cases, but the accessibility tree will represent it as running text with no semantics attached to it. A <figure> might be more appropriate in many cases:

<figure>
	<cool-bird></cool-bird>
	<figcaption>A cool-looking bird wearing sunglasses and a fedora.</figcaption>
</figure>

You could also provide an image as alternative content, e.g. for sighted users with JS disabled or failing. In that case, the image itself should also have its own alternative content.

<cool-bird>
	<img
		src="cool-bird.jpg"
		alt="A cool-looking bird wearing sunglasses and a fedora."
	/>
</cool-bird>

There are many other options for how to represent the content, and what the right one is will ultimately depend on the content and its context. If you know what you’re doing, you could even add aria attributes to your custom element, to have it directly participate in the accessibility tree:

<cool-bird
	role="img"
	aria-label="A cool-looking bird wearing sunglasses and a fedora."
></cool-bird>

Do note that this method on its own does not produce any visual alternative content for the no-JS case, and you would need to take care to manually provide any expected behaviour that goes with the role you set. Leveraging the built-in semantics and behaviour of existing HTML elements is a better choice in most cases. But the option is there, like anything else in HTML that fits your use case.

Users on a bad connection

Another case where fallback content would be handy is if your visitor is e.g. on a train and their connection is spotty, so that the JavaScript for rendering the 3D scene doesn’t load. However, in this case the fallback content is not presented visually like in the JS-disabled case. On one level this is unfortunate, but it’s understandable - it would be pretty annoying to see the fallback content flash by before everything is set up.

We could work around this if we want, maybe by having ThreeWorld add the canvas dynamically when it’s connected, or by adding onerror handlers to our script files that replaces the canvas with its children when the script fails to load. I might write a follow-up on that at some point.

Passing data to objects

Chances are you’re going to want to configure some object properties more dynamically - having them all hard-coded in the constructor makes them hard to update after initialization, and doesn’t encourage component reuse.

There are two main ways to pass data to custom elements - attributes and properties. There are benefits and drawbacks to both in this context:

  • Attributes can only accept strings, and Three.js objects often need other data types as input, like numbers and objects. To pass those as attributes you’d need to serialize the value to a string somehow and deserialize it in the element class before you use it.
  • On the other hand, attributes make for a friendly, declarative API even in a vanilla setup, where the properties are configured along with the element they affect.
  • They’re also trivial to inspect and edit through the elements panel of most browser dev tools.
  • Properties, being JavaScript data, can’t be directly passed through HTML but have to be passed via script, which can be clunky in some cases. If you’re working in a JS component framework, many of them provide an abstraction for letting you pass properties via their template syntax to make this easier.
  • How to inspect and edit element properties in browser dev tools is maybe less obvious, but it’s absolutely possible and in some ways more powerful.
  • With properties you get a more straightforward data flow for non-string data - no serialization needed.

If it seems like a hard choice, you don’t really need to choose, necessarily - attributes can update properties and vice versa. A lot of native HTML elements do this. Let’s do it this way in our example as well. We’ll add x, y, and z values to set the position of our box. For fun, we’ll throw in a color attribute as well.

three-box.js
class ThreeBox extends HTMLElement {
	// other methods...
 
	// Read and parse attributes when the corresponding
	// properties are accessed.
	get x() {
		const value = this.getAttribute('x') ?? 0;
		return Number.parseFloat(value);
	}
	get y() {
		const value = this.getAttribute('y') ?? 0;
		return Number.parseFloat(value);
	}
	get z() {
		const value = this.getAttribute('z') ?? 0;
		return Number.parseFloat(value);
	}
	get color() {
		return this.getAttribute('color') ?? DEFAULT_COLOR;
	}
 
	// When the properties are changed, update the
	// attribute instead.
	set x(value) {
		this.setAttribute('x', value);
	}
	set y(value) {
		this.setAttribute('y', value);
	}
	set z(value) {
		this.setAttribute('z', value);
	}
	set color(value) {
		this.setAttribute('color', value);
	}
 
	// Mark the attributes as observed so we can
	// react to changes.
	static get observedAttributes() {
		return ['x', 'y', 'z', 'color'];
	}
 
	// Since we marked them as observed, this method will be
	// called whenever one of them is changed.
	attributeChangedCallback(name, _oldValue, _newValue) {
		switch (name) {
			// Even though the `newValue` is passed to this method,
			// we get it from the property getters instead to ensure
			// the values are parsed correctly and defaults applied.
			case 'x':
				this.box.position.setX(this.x);
				break;
			case 'y':
				this.box.position.setY(this.y);
				break;
			case 'z':
				this.box.position.setZ(this.z);
				break;
			case 'color':
				this.box.material.color.set(this.color);
				break;
		}
	}
}

Now we can start getting a bit more variety in our scene! Let’s add some more boxes and configure them via attributes:

index.html / <canvas>
<three-world>
	<figure>
		<three-box x="-3" y="0" z="-1" color="goldenrod"></three-box>
		<three-box x="0" y="0" z="1.5" color="cornflowerblue"></three-box>
		<three-box x="1.5" y="1" z="0" color="hotpink"></three-box>
		<figcaption>
			A blue box and a pink box close up, and a gold-colored box in the
			background, all viewed at an angle.
		</figcaption>
	</figure>
</three-world>

The result

Below is a Glitch demo of what it looks like at this point. You can click the “View source” button to explore the code in its entirety, or Remix it if you want to tinker.

As demos go, it’s not very flashy, but it’s more to prove out a concept than anything else. Please do try editing the boxes in the element inspector UI in your browser’s dev tools, or via JS in the console. Honestly, the first time I realised this capability of web components I was a bit mindblown.

Wrapping up

When I first started writing this post, I was planning to go into animation, interactivity, and much more. But it’s already getting pretty lengthy, so that’ll be a topic for another time - maybe this post will grow into a series?

If you’ve been reading along until now, I hope you’ll agree with me that this is a really cool use case for web components that plays to their strengths and avoids their weaknesses. I’m very excited to keep exploring this space.

Further reading

  • Three.js documentation: Includes installation instructions and some more in-depth explanation of the scene setup.
  • Three.js Fundamentals A slightly hidden-away but very useful part of the manual that dives into the underlying concepts of Three.js a bit more, which is helpful for deeper understanding.
  • Three.js Journey: Paid course that seems quite thorough. Includes a whole chapter just on declarative usage through React Three Fiber.
  • MDN - Web components: A good primer on the concepts and basic usage of the different web component APIs.
  • Frontend Masters - Web components: Paid course by Dave Rupert that goes through web components in great detail. Also includes a demonstration of the previously mentioned A-Frame library.