Build 3D web components with Three.js
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 usingdocument.querySelectorAll('cool-bird')
, dispatch a customchirp
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!
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:
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…
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.
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.
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:
… 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:
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.
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.
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:
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:
If we load the page now, we should see something like this:
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:
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:
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.
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:
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.
Now we can start getting a bit more variety in our scene! Let’s add some more boxes and configure them via attributes:
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.