Web Components FTW!

by Chris Ferdinandi published on

Web Components are a collection of technologies that you can use to create reusable custom elements, with built-in interactivity, automatically scoped (or encapsulated) from the rest of your code.

They have a wide range of features and functionality (some good, some bad, some ugly), but today, we're going to look at how to create your first Web Component using the most cliche of examples: the counter button.

We'll learn how this...

<counter-button></counter-button>

Automagically becomes somethings like this under-the-hood...

<button onclick="increase()">Clicked 0 Times</button>
<script>
let count = 0;

function increase (event) {
count++;
event.target.textContent = `Clicked ${count} Times`;
}
</script>

Let's dig in!

Creating a custom element

First, we'll create a custom element.

You can name it anything you want, but it must include at least one dash (-). Single-word web components are not allowed (those are reserved for native elements).

Let's uncreatively name ours counter-button.

<counter-button></counter-button>

Next, we need to register our custom element with JavaScript.

We do that with the customElements.define() method. We'll pass in the name of our custom element as the first argument, and a Class that extends the HTMLElement API as our second.

When our page loads, this will automatically find any custom elements with the name we provided and instantiate a new instance of our Class on them.

customElements.define('counter-button', class extends HTMLElement {
// Stuff will happen here!
});

Inside the class, we'll add the constructor() method. This runs when a new instance of the Class is created.

In it, we'll user the super() operator to make sure we have access to the parent class (in this case, the HTMLElement object) properties.

customElements.define('counter-button', class extends HTMLElement {

/**
* The class constructor object
*/

constructor () {

// Always call super first in constructor
super();

}

});

Next, we'll add a property to track the number of clicks: this.count. You can think of this like Component state in your favorite JS library or framework.

(In a JavaScript Class, the this keyword refers to the current instance of the Class.)

customElements.define('counter-button', class extends HTMLElement {

/**
* The class constructor object
*/

constructor () {

// Always call super first in constructor
super();

// Track the count
this.count = 0;

}

});

Finally, we'll use the innerHTML property to render a button into the custom element (this), with our count state displayed as part of the text.

customElements.define('counter-button', class extends HTMLElement {

/**
* The class constructor object
*/

constructor () {

// Always call super first in constructor
super();

// Track the count
this.count = 0;

// Render HTML
this.innerHTML =
`<button>Clicked ${this.count} Times</button>`;

}

});

Now, a <button> is automatically rendered into the UI wherever we include our custom element. It looks like this in the HTML.

<counter-button>
<button>Clicked 0 Times</button>
</counter-button>

Adding interactivity

Web Components have "lifecycle functions" that run when various things happen.

  • The constructor() method is run when the element is created, before its injected into the UI.
  • The connectedCallback() method is run when the element is injected into the DOM, and again whenever it's moved or appended elsewhere.
  • The disconnectedCallback() method is run whenever the element is removed from the DOM.

When the <button> is clicked, we want to increase our count by 1 and update the UI.

We don't need to listen for clicks until the element is loaded into the DOM, so we'll use connectedCallback() method to add a click event listener.

Inside our Web Component Class, this is the custom element (<counter-button>).

We'll attach a click event listener to this. Inside the event handler function, we'll increase our count by 1, then render new text into the firstElementChild, the <button> element.

customElements.define('counter-button', class extends HTMLElement {

/**
* The class constructor object
*/

constructor () {
// ...
}

/**
* Runs each time the element is appended to the DOM
*/

connectedCallback () {
this.addEventListener('click', function () {
this.count++;
this.firstElementChild.textContent = `Clicked ${this.count} Times`;
});
}

});

Now, whenever the button is clicked, the count and UI are automatically updated.

If you have multiple <counter-button> elements on a page, each one is self-contained and encapsulated. Updating the count on one doesn't affect the UI of the other.

Here's a demo.

Detecting attribute changes

The web component lifecycle includes an additional function, attributeChangedCallback(), that runs when attributes on a custom element are added, removed, or changed in value.

You can use it to detect attribute changes and run code in response.

For example, imagine if you wanted to let third-party JavaScript update the count on a <counter-button> by setting a count attribute, like this...

let counter = document.querySelector('counter-button');
counter.setAttribute('count', 42);

First, we need to create a static getter method named observedAttributes().

This function needs to return an array of attributes to watch. For performance reasons, only attributes listed in this array will be observed by the attributeChangedCallback() function.

We'll return an array with the count attribute.

/**
* Create a list of attributes to observe
*/

static get observedAttributes () {
return ['count'];
}

Next, we'll add an attributeChangedCallback() function.

It accepts three arguments: the name of the attribute that's been changed, its oldValue, and its newValue.

/**
* Runs when the value of an attribute is changed on the component
* @param {String} name The attribute name
* @param {String} oldValue The old attribute value
* @param {String} newValue The new attribute value
*/

attributeChangedCallback (name, oldValue, newValue) {
console.log('changed', name, oldValue, newValue, this);
}

At this point, if we set or update the count attribute on our counter-button element, the attributeChangedCallback() will log some stuff into the console.

let counter = document.querySelector('counter-button');

// Nothing will happen here, because we're not watching this attribute
counter.setAttribute('hello', 'you');

// logs "changed" "count" null 42
counter.setAttribute('count', 42);

If we were observing more than one attribute, we would want to first check what the name was before doing anything in the attributeChangedCallback() function.

/**
* Runs when the value of an attribute is changed on the component
* @param {String} name The attribute name
* @param {String} oldValue The old attribute value
* @param {String} newValue The new attribute value
*/

attributeChangedCallback (name, oldValue, newValue) {

// If the [count] attribute
if (name === 'count') {
// ...
}

}

For this web component, though, the function only runs for the count attribute, so we don't have to check the name, nor do we have to worry about the oldValue.

First, we'll pass the newValue into the parseFloat() method to make sure its to a number. We'll also check if it's Not a Number (isNaN()).

Then, we'll update the count property, and render the new count into the firstElementChild (the <button>).

/**
* Runs when the value of an attribute is changed on the component
* @param {String} name The attribute name
* @param {String} oldValue The old attribute value
* @param {String} newValue The new attribute value
*/

attributeChangedCallback (name, oldValue, newValue) {

// Make sure the new value is a number
let num = parseFloat(newValue);
if (isNaN(num)) return;

// Update the count and render the UI
this.count = num;
this.firstElementChild.textContent = `Clicked ${this.count} Times`;

}

Now, whenever the count attribute is set or updated on our custom element, the count and UI are updated to match.

Here's another demo.

The Shadow DOM

The Shadow DOM is a special DOM, whose contents are separate and often isolated from the main DOM.

Web Components can use the Shadow DOM to encapsulate their child elements, and avoid the naming collisions and unintended side effects that sometimes happen when code is used by teams or across projects.

For example, with our current Web Component, someone could (hopefully accidentally!) wipe out all of the content inside the element.

let counter = document.querySelector('counter-button');
counter.innerHTML = '';

With the Shadow DOM, content inside the custom element cannot be accessed or modified with outside JavaScript or CSS.

<counter-button>
#shadow-root (closed)
<button>Clicked 0 Times</button>
</counter-button>

Inside our constructor, immediately after running the super() method, we can use the Element.attachShadow() method to create a shadow DOM for our web component.

It accepts an object of options. The only one we need to specify is the mode.

If we use open as the value, JavaScript from outside of our web component can access the DOM nodes. With a value of closed, only JavaScript inside the web component itself can access the Shadow DOM.

The Element.attachShadow() method returns to the ShadowRoot object. To easily access it in our methods, we'll assign it to the root property for our instance.

/**
* The class constructor object
*/

constructor () {

// Always call super first in constructor
super();

// Creates a shadow root
this.root = this.attachShadow({mode: 'closed'});

// ...

}

Now, instead of injecting content into the web component itself (this), we'll use the ShadowRoot (this.root).

/**
* The class constructor object
*/

constructor () {

// Always call super first in constructor
super();

// Creates a shadow root
this.root = this.attachShadow({mode: 'closed'});

// Track the count
this.count = 0;

// Render HTML
this.root.innerHTML =
`<button>Clicked ${this.count} Times</button>`;

}

Here's a demo of our Web Component with the Shadow DOM.

Opinion: do not use the Shadow DOM by default

While the Shadow DOM protects your code from unintentional side-effects and interactions, but also introduces some of its own challenges.

One of the biggest ones is around styling. Shadow DOM elements do not inherit global styles like elements in the main DOM do. There are a few techniques for styling elements in the Shadow DOM, but they're all a bit clunky and require more work from you as a developer.

There are also some accessibility challenges related to the Shadow DOM. Manuel has documented many of them here.

Unfortunately, some of Web Components best features (like template and slot) require the Shadow DOM.

But in my opinion, the tradeoffs and challenges with using the Shadow DOM outweigh the benefits it provides in most cases. It has its place, but the Light DOM (ie. the "regular" DOM) should be the default approach in most instances.

Why use web components?

You don't need web components to create a counter button... or any component, for that matter. So why use them?

First, they make it a lot more obvious what complex components are in your UI. An element called loading-icon is a lot more obvious than a set of empty nested div elements with a .loading-ring class on it.

<!-- This is clear -->
<loading-icon></loading-icon>

<!-- This is clunky -->
<div class="visually-hidden" role="status">
Waiting to load...
</div>
<div class="loading-ring">
<div></div>
<div></div>
<div></div>
<div></div>
</div>

If you're working with a team of developers or creating a design system, web components provide a handful of additional benefits.

First, because the web component generates all of the behind-the-scenes HTML, you don't need to worry about developers forgetting something important.

<!-- This is the wrong class -->
<div class="loading-rings">
<div></div>
<div></div>
<div></div>
<div></div>
</div>

<!-- This one is missing a div -->
<div class="loading-ring">
<div></div>
<div></div>
<div></div>
</div>

If you fix a bug or update your component, you don't have to manually update a bunch of HTML everywhere the component is used. You only need to update the JavaScript.

For example, imagine that the original version of your loading icon component did not include an ARIA live region to announce when the content was loaded to screen readers. You realize the mistake and update the component.

With a traditional HTML component, any developer who uses it will need to update their HTML, JavaScript, and CSS files to include the fix. With web components, they only have to update the JavaScript file.

<!-- 
No changes needed
The component handles the HTML for you
-->

<loading-icon></loading-icon>

<!-- This is the only change needed -->
<script src="loading-icon.v2.js"></script>

To summarize, the benefits of web components include...

  • Clarity and readability
  • A simpler and less error prone developer experience
  • Ease of updating components
  • Scoping and encapsulation

Learn more!

This article only scratched the surface on Web Components and what they're capable of.

You can learn more over at MDN. I also have a standalone course and a project-based workshop on Web Components as part of my Lean Web Club.

About Chris Ferdinandi

Chris Ferdinandi helps people learn vanilla JavaScript, and believes there's a simpler, more resilient way to make things for the web.
He creates courses and workshops and works with amazing clients. His developer tips newsletter is read by thousands of developers each weekday.
He loves pirates, puppies, and Pixar movies. Learn more at GoMakeThings.com.