Web Components

Notes on Web Components

Updated: 03 September 2023

Basics from CSS Tricks

Most code snippets used here are from the above series

Introduction

Web components are custom HTML Elements which are built with Javascript and make use of the Shadow DOM to encapsulate CSS and JS and user-defined HTML templates

At present Web Components are available in most major browsers with polyfills for IE and Edge

HTML Templates

HTML templates allow us to define reusable pieces of HTML that will not be rendered until used by a script

An template can be defined and used as follows

1
<template id="book-template">
2
<li><span class="title"></span> &mdash; <span class="author"></span></li>
3
</template>
4
5
<ul id="books"></ul>

We can then create instances of the element by using javascript to use the template HTML and insert it into the UL

1
// some data to use
2
const books = [
3
{ title: 'The Great Gatsby', author: 'F. Scott Fitzgerald' },
4
{ title: 'A Farewell to Arms', author: 'Ernest Hemingway' },
5
{ title: 'Catch 22', author: 'Joseph Heller' },
6
]
7
8
// get the template
9
const fragment = document.getELementById('book-template')
10
11
// loop through data
12
books.forEach((book) => {
13
// create an copy of the template
14
const instance = document.importNode(fragment.content, true)
15
16
// set the inner HTML for the different content sections
17
instance.querySelector('.title').innerHTML = book.title
18
instance.querySelector('.title').innerHTML = book.author
19
20
// add the new instance to the books list
21
document.getElementById('books').appendChild(instance)
22
})

The importNode function takes in a fragment content and a boolean that tells the browser whether or not to copy just the parent or all of it’s subtree

Since templates are regulat HTML Elements, they can contain things like Javascript and CSS, for example:

1
<template id="template">
2
<script>
3
const button = document.getElementById('click-me')
4
button.addEventListener('click', (event) => alert(event))
5
</script>
6
<style>
7
#click-me {
8
all: unset;
9
background: tomato;
10
border: 0;
11
border-radius: 4px;
12
color: white;
13
font-family: Helvetica;
14
font-size: 1.5rem;
15
padding: 0.5rem 1rem;
16
}
17
</style>
18
<button id="click-me">Log click event</button>
19
</template>

The problem witht the above method is that the styles and functionality of the component can still impact the rest of the DOM once an instance/s are created

Custom Elements

Custom elements are elements that can be defined by users. These elements must have a - in their names

This markup can be shared between different frameworks

A custom element can be defined with

1
class HelloWorldComponent extends HTMLElement {
2
connectedCallback() {
3
this.innerHTML = `<h1>Hello World</h1>`
4
}
5
}
6
7
customElements.define('hello-world', HelloWorldComponent)

And can be used in HTML as follows

1
<hello-world></hello-world>

All Custom Elements must extend HTMLElement in order to be registered by the browser

The customElements API allows us to create custom HTML tags that can be used on any document that has the class definition for the element

Custom elements make use of lifecycle methods

The constructor is used to set up the basics of the element, and connectedCallback is used to add content to the element, set up event listeners or generally initialize the component

Typically an element’s state isn based on the attributes that are present on the element, for example a custom attribute open. We can watch changes to attributes in the attributeChangedCallback which is called whenever an element’s observedAttributes are changed

We can create a dialog component which makes use of the above

1
class OneDialog extends HTMLElement {
2
static get observedAttributes() {
3
return ['open']
4
}
5
6
attributeChangedCallback(attrName, oldValue, newValue) {
7
if (newValue !== oldValue) {
8
this[attrName] = this.hasAttribute(attrName)
9
}
10
}
11
12
connectedCallback() {
13
const template = document.getElementById('one-dialog')
14
const node = document.importNode(template.content, true)
15
this.appendChild(node)
16
}
17
}

The attributeChangedCallback helps us to keep our internal element state and the external attributes in sync by updating out internal state when the external attributes are changed

Additionally we can create a getter and setter for the open property and make use of those to update the state using the following code

1
class OneDialog extends HTMLElement {
2
static get boundAttributes() {
3
return ['open']
4
}
5
6
attributeChangedCallback(attrName, oldValue, newValue) {
7
this[attrName] = this.hasAttribute(attrName)
8
}
9
10
connectedCallback() {
11
const template = document.getElementById('one-dialog')
12
const node = document.importNode(template.content, true)
13
this.appendChild(node)
14
}
15
16
get open() {
17
return this.hasAttribute('open')
18
}
19
20
set open(isOpen) {
21
if (isOpen) {
22
this.setAttribute('open', true)
23
} else {
24
this.removeAttribute('open')
25
}
26
}
27
}

Using the above we can update the state based on the attribute as well as vice versa

Most elements will involve some boilerplate code to keep the element state in sync, we can instead encapsulate this functionality in an abstract class that we can extend for our custom elements, this will loop and allocate the respective attributes to the element state

1
class AbstractClass extends HTMLElement {
2
constructor() {
3
super();
4
// Check to see if observedAttributes are defined and has length
5
if (this.constructor.observedAttributes && this.constructor.observedAttributes.length) {
6
// Loop through the observed attributes
7
this.constructor.observedAttributes.forEach(attribute => {
8
// Dynamically define the property getter/setter
9
Object.defineProperty(this, attribute, {
10
get() { return this.getAttribute(attribute); },
11
set(attrValue) {
12
if (attrValue) {
13
this.setAttribute(attribute, attrValue);
14
} else {
15
this.removeAttribute(attribute);
16
}
17
}
18
}
19
});
20
}
21
}
22
}
23
24
// Instead of extending HTMLElement directly, we can now extend our AbstractClass
25
class SomeElement extends AbstractClass { /** Omitted */ }
26
27
customElements.define('some-element', SomeElement);

Back to the dialog - we can add the ability for the dialog to show or hide itself by modifying it’s classes and add and remove the relevant event listeners

We also have the disconnectedCallback lifecycle method that allows us to do the necessary cleanup for the component

1
class OneDialog extends HTMLElement {
2
static get observedAttributes() {
3
return ['open']
4
}
5
6
constructor() {
7
super()
8
this.close = this.close.bind(this)
9
}
10
11
attributeChangedCallback(attrName, oldValue, newValue) {
12
if (oldValue !== newValue) {
13
this[attrName] = this.hasAttribute(attrName)
14
}
15
}
16
17
connectedCallback() {
18
const template = document.getElementById('dialog-template')
19
const node = document.importNode(template.content, true)
20
this.appendChild(node)
21
22
this.querySelector('button').addEventListener('click', this.close)
23
this.querySelector('.overlay').addEventListener('click', this.close)
24
this.open = this.open
25
}
26
27
disconnectedCallback() {
28
this.querySelector('button').removeEventListener('click', this.close)
29
this.querySelector('.overlay').removeEventListener('click', this.close)
30
}
31
32
get open() {
33
return this.hasAttribute('open')
34
}
35
36
set open(isOpen) {
37
this.querySelector('.wrapper').classList.toggle('open', isOpen)
38
this.querySelector('.wrapper').setAttribute('aria-hidden', !isOpen)
39
if (isOpen) {
40
this._wasFocused = document.activeElement
41
this.setAttribute('open', '')
42
document.addEventListener('keydown', this._watchEscape)
43
this.focus()
44
this.querySelector('button').focus()
45
} else {
46
this._wasFocused && this._wasFocused.focus && this._wasFocused.focus()
47
this.removeAttribute('open')
48
document.removeEventListener('keydown', this._watchEscape)
49
this.close()
50
}
51
}
52
53
close() {
54
if (this.open !== false) {
55
this.open = false
56
}
57
const closeEvent = new CustomEvent('dialog-closed')
58
this.dispatchEvent(closeEvent)
59
}
60
61
_watchEscape(event) {
62
if (event.key === 'Escape') {
63
this.close()
64
}
65
}
66
}

While the above helps us to encapsulate functionality, it say’s nothing of the stylings in the component which can still impact, and be impacted by the rest of the DOM

In order to do that we can make use of the Shadow DOM

Shadow DOM

The Shadow DOM is an encapsulated section of the DOM which helps to isolate pieces of the DOM including any CSS

When targeting the shadow DOM we make use of shadowRoot.querySelector where shadowRoot is a reference to the shadow-element

A fragment of ShadowDOM can be created by making use of attachShadow and the <slot></slot> element to include the content from the outer document

1
<div id="shadow-ref">Shadow Button Text</div>
2
<button id="button">Document Button Text</button>
1
const shadowRoot = document
2
.getElementById('shadow-ref')
3
.attachShadow({ mode: 'open' })
4
shadowRoot.innerHTML = `<style>
5
button {
6
background-color: blue;
7
}
8
</style>
9
<button id="button"><slot></slot> tomato</button>`

The above will render two buttons, the one in the shadow DOM will be blue, while the other will be unaffected by the CSS

1
class OneDialog extends HTMLElement {
2
constructor() {
3
super()
4
this.attachShadow({ mode: 'open' })
5
this.close = this.close.bind(this)
6
}
7
}

By calling attachShadow with {mode: 'open'} we tell the element to save a reference to the shadow root which can be accessed with element.shadowRoot

If we use {mode: 'closed'} we will additionally need to store a reference to the root itself. We can do this using a WeakMap which uses the shadow root as the value and the element as the key

1
const shadowRoots = new WeakMap()
2
3
class ClosedRoot extends HTMLElement {
4
constructor() {
5
super()
6
const shadowRoot = this.attachShadow({ mode: 'closed' })
7
shadowRoots.set(this, shadowRoot)
8
}
9
10
connectedCallback() {
11
const shadowRoot = shadowRoots.get(this)
12
shadowRoot.innerHTML = `<h1>Hello from a closed shadow root!</h1>`
13
}
14
}

Usually we would not use a shadow root that is closed, this is more for elements like <audio> that use the shadow DOM for it’s implementation

The problem with using the shadow DOM is that the element needs to now interact with this instead of the light DOM. So the implementation needs to be updated as follows

1
class OneDialog extends HTMLElement {
2
constructor() {
3
super()
4
this.attachShadow({ mode: 'open' })
5
this.close = this.close.bind(this)
6
}
7
8
connectedCallback() {
9
const { shadowRoot } = this
10
const template = document.getElementById('one-dialog')
11
const node = document.importNode(template.content, true)
12
shadowRoot.appendChild(node)
13
14
shadowRoot.querySelector('button').addEventListener('click', this.close)
15
shadowRoot.querySelector('.overlay').addEventListener('click', this.close)
16
this.open = this.open
17
}
18
19
disconnectedCallback() {
20
this.shadowRoot
21
.querySelector('button')
22
.removeEventListener('click', this.close)
23
this.shadowRoot
24
.querySelector('.overlay')
25
.removeEventListener('click', this.close)
26
}
27
28
set open(isOpen) {
29
const { shadowRoot } = this
30
shadowRoot.querySelector('.wrapper').classList.toggle('open', isOpen)
31
shadowRoot.querySelector('.wrapper').setAttribute('aria-hidden', !isOpen)
32
if (isOpen) {
33
this._wasFocused = document.activeElement
34
this.setAttribute('open', '')
35
document.addEventListener('keydown', this._watchEscape)
36
this.focus()
37
shadowRoot.querySelector('button').focus()
38
} else {
39
this._wasFocused && this._wasFocused.focus && this._wasFocused.focus()
40
this.removeAttribute('open')
41
document.removeEventListener('keydown', this._watchEscape)
42
}
43
}
44
45
close() {
46
this.open = false
47
}
48
49
_watchEscape(event) {
50
if (event.key === 'Escape') {
51
this.close()
52
}
53
}
54
}
55
56
customElements.define('one-dialog', OneDialog)

We can also render content using <slot>, where we can include named slot’s in our light DOM, for example:

1
<one-dialog>
2
<span slot="heading">Hello world</span>
3
<div>
4
<p>Lorem ipsum dolor.</p>
5
</div>
6
</one-dialog>

Which can then be rendered in it’s respective pieces in our element with

1
<h1 id="title"><slot name="heading"></slot></h1>
2
<div id="content" class="content">
3
<slot></slot>
4
</div>

Furthermore, we can give the element a template (or different templates) by way of a new attribute for the element:

1
get template() {
2
return this.getAttribute('template');
3
}
4
5
set template(template) {
6
if (template) {
7
this.setAttribute('template', template);
8
} else {
9
this.removeAttribute('template');
10
}
11
this.render();
12
}

And then defining a render method to use that template with

1
connectedCallback() {
2
this.render();
3
}
4
5
render() {
6
const { shadowRoot, template } = this;
7
const templateNode = document.getElementById(template);
8
shadowRoot.innerHTML = '';
9
if (templateNode) {
10
const content = document.importNode(templateNode.content, true);
11
shadowRoot.appendChild(content);
12
} else {
13
shadowRoot.innerHTML = `<!-- template text -->`;
14
}
15
shadowRoot.querySelector('button').addEventListener('click', this.close);
16
shadowRoot.querySelector('.overlay').addEventListener('click', this.close);
17
this.open = this.open;
18
}

Lastly, you can use attributeChangedCallback to update the component when the template is changed

1
static get observedAttributes() { return ['open', 'template']; }
2
3
attributeChangedCallback(attrName, oldValue, newValue) {
4
if (newValue !== oldValue) {
5
switch (attrName) {
6
/** Boolean attributes */
7
case 'open':
8
this[attrName] = this.hasAttribute(attrName);
9
break;
10
/** Value attributes */
11
case 'template':
12
this[attrName] = newValue;
13
break;
14
}
15
}
16
}

Currently the only reliable way to style components is with the <style> tag, these can however make use of css variables which pass through into the shadow DOM

Proposed functionality

Constructible Stylesheets

This would allow stylesheets to be defined in JS and be applied on multiple nodes

1
const everythingTomato = new CSSStyleSheet()
2
everythingTomato.replace('* { color: tomato; }')
3
4
document.adoptedStyleSheets = [everythingTomato]
5
6
class SomeCompoent extends HTMLElement {
7
constructor() {
8
super()
9
this.adoptedStyleSheets = [everythingTomato]
10
}
11
12
connectedCallback() {
13
this.shadowRoot.innerHTML = `<h1>CSS colors are fun</h1>`
14
}
15
}

This could potentially be used for the proposed CSS modules for example:

1
import styles './styles.css';
2
3
class SomeCompoent extends HTMLElement {
4
constructor() {
5
super();
6
this.adoptedStyleSheets = [styles];
7
}
8
}

Part and Theme

The ::part() and ::theme() selectors could allow you to expose elements of a component for styling

1
class SomeOtherComponent extends HTMLElement {
2
connectedCallback() {
3
this.attachShadow({ mode: 'open' })
4
this.shadowRoot.innerHTML = `
5
<style>h1 { color: rebeccapurple; }</style>
6
<h1>Web components are <span part="description">AWESOME</span></h1>
7
`
8
}
9
}
10
11
customElements.define('other-component', SomeOtherComponent)
1
other-component::part(description) {
2
color: tomato;
3
}

::theme() is similar to ::part() but it allows elements to be styled from anywhere whereas the latter requires it to be specifically selected

Tooling and Integration