What are they?
Web Components are a set of technologies that allow us to extend the standard set of HTML elements with additional elements.
The three main technologies are:
- Custom elements
-
A way to extend HTML so that instead of having to build all our markup out of
<div>
,<input>
,<span>
and friends, we can build with higher-level primitives. - Shadow DOM
- Extending custom elements to have their own separate DOM, isolating complex behavior inside the element from the rest of the page.
- HTML templates
-
Extending custom elements with reusable markup blocks using the
<template>
and<slot>
tags, for quickly generating complex layouts.
Those 3 bullets tell you everything and nothing at the same time. This probably isn't the first tutorial on Web Components you've seen, and you may find them a confusing topic. However, it's not that complicated as long as you build them up step by step ...
A simple component
Let's start with the most basic form, a custom element that says 'hello world!':
We can use it in a page like this:
Which outputs this page:
So what's happening here?
We created a new HTML element, registered as the x-hello-world
tag,
and used it on the page. When we did that, we got the following DOM structure:
- body (node)
- x-hello-world (node)
- 'hello world!' (textContent)
- x-hello-world (node)
Explaining the code of the custom element line by line:
class HelloWorldComponent extends HTMLElement {
- Every custom element is a class extending
HTMLElement
. In theory it's possible to extend other classes – likeHTMLButtonElement
to extend a<button>
– but in practice this doesn't work yet in Safari. connectedCallback() {
- This method is called when our element is added to the DOM, which means the element is ready to make DOM updates. Note that it may be called multiple times when the element or one of its ancestors is moved around the DOM.
this.textContent = 'hello world!';
- The
this
in this case refers to our element, which has the full HTMLElement API, including its ancestorsElement
andNode
, on which we can find thetextContent
property, which is used to add the'hello world!'
string to the DOM. customElements.define('x-hello-world', HelloWorldComponent);
- For every web component
window.customElements.define
must be called once to register the custom element's class and associate it with a tag. After this line is called the custom element becomes available for use in HTML markup, and existing uses of it in already rendered markup will have their constructors called.
An advanced component
While the simple version above works for a quick demo, you'll probably want to do more pretty quickly:
- Adding DOM elements as children to allow for richer content.
- Passing in attributes, and updating the DOM based on changes in those attributes.
- Styling the element, preferably in a way that's isolated and scales nicely.
- Defining all custom elements from a central place, instead of dumping random script tags in the middle of our markup.
To illustrate a way to do those things with custom elements, here's a custom element <x-avatar>
that implements
a simplified version of the NextUI Avatar component (React):
Some key elements that have changed:
- The
observedAttributes
getter returns the element's attributes that when changed causeattributeChangedCallback()
to be called by the browser, allowing us to update the UI. -
The
connectedCallback
method is written in the assumption that it will be called multiple times. This method is in fact called when the element is first added to the DOM, but also when it is moved around. -
The
update()
method handles initial render as well as updates, centralizing the UI logic. Note that this method is written in a defensive way with theif
statement, because it may be called from theattributeChangedCallback()
method beforeconnectedCallback()
creates the<img>
element. - The exported
registerAvatarComponent
function allows centralizing the logic that defines all custom elements in an application.
Once rendered this avatar component will have this DOM structure:
- body (node)
- x-avatar (node)
- img (node)
- src (attribute)
- alt (attribute)
- img (node)
- x-avatar (node)
For styling of our component we can use a separate css file:
Notice that:
- Because we know what the tag of our component is, we can easily scope the styles by prepending them with
x-avatar
, so they won't conflict with the rest of the page. - Because a custom element is just HTML, we can style based on the element's custom attributes in pure CSS, like the
size
attribute which resizes the component without any JavaScript.
An example that shows the two different sizes on a webpage:
The HTML for this example centralizes the JavaScript and CSS logic to two index files, to make it easier to scale out to more web components. This pattern, or a pattern like it, can keep things organized in a web application that is built out of dozens or hundreds of different web components.
The use of the CSS @import
keyword may seem surprising as this keyword is often frowned upon for performance reasons,
but in modern browsers over HTTP/2 and in particular HTTP/3 the performance penalty of this approach is not that severe.
Adding Children
Allowing children to be added to a web component is not hard. In fact, it is the default behavior. To see how this works, let's extend the avatar example by wrapping it with a badge:
To clarify, this is the DOM structure that is created:
- div (node)
- x-badge (node)
- content (attribute)
- span (node, showing content)
- x-avatar (node)
- input (node)
- x-badge (node)
The x-avatar
component is identical to the previous example, but how does the x-badge
work?
Some notes on what's happening here:
this.insertBefore
-
Care must be taken to not overwrite the children already added in the markup, for example by assigning to
innerHTML
. In this case the span that shows the badge is inserted before the child elements.This also means that for custom elements that should not have children, this can be enforced by calling
this.innerHTML = ''
fromconnectedCallback()
. set content(value) {
-
Custom element attributes can only be accessed from JavaScript through the
setAttribute()
andgetAttribute()
methods. To have a cleaner JavaScript API a setter and getter must be created for a class property that wraps the custom element'scontent
attribute. See theindex.html
above for where this is called.
Bells and whistles
Having seen what regular web components look like, we're now ready to jump up to the final difficulty level of web components, leveraging the more advanced features of Shadow DOM and HTML templates.
This can all be brought together in this page layout example, that defines a new <x-header>
component:
This is the code for the newly added <x-header>
component:
There is a lot happening in header.js
, so let's unpack.
const template = document.createElement('template');
-
The header code starts out by creating an HTML template.
Templates are fragments of HTML that can be easily cloned and appended to a DOM.
For complex web components that have a lot of markup, the use of a template is often more convenient.
By instantiating the template outside the class, it can be reused across all instances of the
<x-header>
component. <link rel="stylesheet" href="${new URL('header.css', import.meta.url)}">
-
Because this component uses a shadow DOM, it is isolated from the styles of the containing page and starts out unstyled.
The
header.css
needs to be imported into the shadow DOM using the<link>
tag. The specialimport.meta.url
trick imports the CSS file from the same path as theheader.js
file. <slot></slot>
-
The
<slot>
element is where the child elements will go (like the<x-badge>
child of<x-header>
). Putting child elements in a slot is similar to using achildren
prop in a React component. The use of slots is only possible in web components that have a shadow DOM. constructor() {
-
This is the first example that uses a constructor. The constructor is called when the element is first created, but before it's ready for DOM interaction. The default behavior of a constructor is to call the parent class's constructor
super()
. So if all that is needed is the default HTMLElement constructor behavior then no constructor needs to be specified.The reason it is specified here is because the constructor is guaranteed to be called exactly once, which makes it the ideal place to attach a shadow DOM.
if (!this.shadowRoot) { this.attachShadow({ mode: 'open' });
-
attachShadow
attaches a shadow DOM to the current element, an isolated part of the DOM structure with CSS separated from the containing page, and optionally with the shadow content hidden away from the parent page's JavaScript context (ifmode: 'closed'
is set). For web components that are used in a known codebase, it is usually more convenient to use them in open mode, as is done here.if (!this.shadowRoot) {
is not strictly necessary, but allows for server-side generated HTML, by making use of declarative shadow DOM. this.shadowRoot.append(template.content.cloneNode(true));
-
The
shadowRoot
property is the root element of the attached shadow DOM, and is rendered into the page as the<x-header>
element's content. The HTML template is cloned and appended into it.The shadow DOM becomes immediately available as soon as
attachShadow
is called, which is why the template can be appended in the constructor, and why theupdate()
method can be called there. For custom elements without shadow DOM rendering the element's content should be deferred untilconnectedCallback()
.
All the new files of this example put together:
As you can see in header.css
, styling the content of a shadow DOM is a bit different:
- The
:host
pseudo-selector applies styles to the element from the light DOM that hosts the shadow DOM (or in other words, to the custom element itself). - The other styles (like
h1
in this example) are isolated inside the shadow DOM. - The shadow DOM starts out unstyled, which is why
reset.css
is imported again.
Passing Data
Everything up to this point assumes that data passed between web components is very simple, just simple numeric and string attributes passing down. A real world web application however passes complex data such as objects and arrays from parent to child components and vice versa.
This example demonstrates the three major ways that data can be passed between web components:
Events
The first way is passing events, usually from child components to their parent component. This is demonstrated by the form at the top of the example.
Every time the Add button is pressed a CustomEvent
of type add
is dispatched using the dispatchEvent
method.
The event data's detail
property carries the submitted form data.
The event is handled one level up:
The update()
method sends the updated list back down to the <santas-list>
and <santas-summary>
components, using the next two methods.
Properties
The second way to pass complex data is by using class properties, as exemplified by the <santas-list>
component:
The list
setter calls the update()
method to rerender the list.
This is the recommended way to pass complex data to stateful web components.
The best practice way of implementing attributes, properties and events is subtle and opinionated. The article making web components behave closer to the platform explains how custom elements can be made to behave like built-in elements, and is recommended reading when making web components that will be embedded in third-party sites.
Methods
The third way to pass complex data is by calling a method on the web component, as exemplified by the <santas-summary>
component:
This is the recommended way to pass complex data to stateless web components.
Complete example
Finally then, here is all the code for the Santa's List application: