Web Components

Today we look at the three technologies that give us native reusable custom elements (a.k.a. "web components"): custom elements, the `template` and `slot` HTML tags, and the shadow DOM.

Table of Contents

Introduction

Today we're going to talk about a set of technologies that are… not my favourite. But they are one of my favourites to talk about.

Web components are a standard implementation (i.e. they work in modern browsers by default) of developer-defined custom HTML elements that can be easily re-used, with their own private CSS.

Sounds great, right?

They definitely should be, and they can be great when used correctly and used for the right jobs, but I find that web components make it easy to make your own life harder and your website worse.

Um, so why are we talking about them?

Well, first off, they're being actively used by major tech companies like Github Opens in a new window and Salesforce Opens in a new window.

Second, the three underlying technologies - the shadow DOM, custom elements, and HTML templating - are all individually really powerful, and, when understood properly, can be used in novel ways.

Third, talking about web components exposes some important topics about what's important to web developers in 2021. These topics include "why do people use frameworks like React & Vue?", "how do we keep the modern web accessible?", "how can I write less, but better, code?", "should our HTML & CSS just be JavaScript?" "seriously, are we still supporting Internet Explorer?", and "is CSS annoying?"

Some Background

There's a right way and a wrong way to use HTML elements.

<!-- Right way -->
<a href="https://securityqa.ca">Learn the right way</a>
 
<!-- Wrong way -->
<span id="fake-link">Learn the wrong way</span>
<script>
  const fl = document.getElementById("fake-link");
  const secqa = "https://securityqa.ca";
  fl.addEventListener("click", function() {
    window.location = secqa;
  });
</script>

The problem with "breaking semantics" (using the wrong elements) is twofold:

First, elements are more than their default styles. Elements signify meaning & context in ways that are important for search-engine optimization (so the web crawlers understand your content) and accessibility (so that assistive technologies like screen readers understand your content).

We'll get to learn plenty more about this in the Accessibility course!

Secondly, HTML elements are more complex than we might realize. They have built-in events (things they communicate) and event listeners (things they respond to) that may affect their behaviour or appearance, or the behaviour and appearance of things around them.

As an example, look at what's required to turn an anchor into a button, both visually (in default, 'hover', 'active' and 'focus' states) and behaviourally (responding both to the mouse and the keyboard).

Re-creating the properties of a native element is a lot of extra work, and that's assuming the developer both understands the properties involved, and remembers them all!

Despite this complexity, developers have long desired to make their own elements.

What else do we know about developers? They hate repeating themselves, they complain that the global nature of CSS rules is messy, and they hate repeating themselves.

There has been work happening on an official version of web components - custom elements that can easily be re-used, with their own private CSS - since at least 2011 Opens in a new window.

Web components turned out to be really hard (or, at least, coming up with a version everyone agreed upon enough to make "official" was really hard). This has been a big source of the appeal behind the modern front-end libraries like React, Svelte, Vue, et al. (although I'd argue their real value offer is sensible state-management, but that's not nearly as fun).

After a lot of hard work, though, web components have been available in all modern browser for about 3 years now. They're a bit thorny, so let's take advantage of the lame-duck period of your semester (after the final assignments have been assigned) to look at this weird new technology.

The Web Component Suite

Web components are comprised of three technologies:

  • The spooky and mysterious Shadow DOM,
  • custom elements, and
  • the <template> and <slot> HTML elements.

We'll dig into each of these in turn, but briefly:

The Shadow DOM
…lets us create a "mini-document" that isn't affected by the normal styles, rules, and behaviours assigned to the rest of the document. This way our components will be able to be independent, and neither break, nor be broken by, the document around it.
The <template> element…
… is an HTML element used for defining a block of HTML that will get used (and, potentially, re-used) in our custom elements. In the same way the when we declare a JavaScript function, it doesn't actually do anything (because we haven't 'called' it yet). We create it because it's going to come in handy later.
The <slot> element…
… is for stuff inside the template that can be different each time we use the template. In this sense, it's like the arguments of a JavaScript function - you can pass in different arguments each time you call the function.
Custom elements
…let us have our own special elements that we can populate using templates. We just have to define their properties in JavaScript, and then it's "off to the races".
The Shadow DOM
…lets us create a "mini-document" that isn't affected by the normal styles, rules, and behaviours assigned to the rest of the document. This way our components will be able to be independent, and neither break, nor be broken by, the document around it.
The <template> element…
… is an HTML element used for defining a block of HTML that will get used (and, potentially, re-used) in our custom elements. In the same way the when we declare a JavaScript function, it doesn't actually do anything (because we haven't 'called' it yet). We create it because it's going to come in handy later.
The <slot> element…
… is for stuff inside the template that can be different each time we use the template. In this sense, it's like the arguments of a JavaScript function - you can pass in different arguments each time you call the function.
Custom elements
…let us have our own special elements that we can populate using templates. We just have to define their properties in JavaScript, and then it's "off to the races".

The Shadow DOM

Wait, what's a DOM

Just in case you missed it, "the DOM" means the document object model. Basically it's how the browser "thinks" about your HTML.

DOM Trees

A "DOM tree" is the browser's model of your content. It's like a flow chart (or family tree) representing all the stuff in your HTML document, including elements and their properties, as well as other stuff in the document like text content.

Objects (like elements and text) are called "nodes" in the DOM tree. The tree "branches" represent nodes nesting inside other nodes, like an <li> element nested inside a <ul>.

Pictured: a diagram of a "DOM tree".

The DOM API

The "DOM API" is the code interface that lets JavaScript manipulate your HTML document's DOM nodes, and their properties.

When you write some JavaScript like:

document.querySelector("p").textContent = "Simon Rules";

… you're not actually editing the HTML document itself. What you're doing is "manipulating the DOM" - changing how the browser "sees" your document.

What gets hidden in the Shadow DOM

The Shadow DOM is a "hidden" document that gets attached to an element. We can attach our own "shadows", but some elements come with their own "shadow" built-in.

The controls in this video element? All part of the shadow DOM.

Now, the controls for this video won't show up if you inspect the video element in your dev tools.

The dev tools not showing the video controls.

That's because the shadow dom attached to the video element is "closed". When we create our own shadows, they will be "open", meaning you'll be able to see them in the dev tools, and even manipulate them with JavaScript.

That doesn't mean your JavaScript will treat the shadow DOM as if it's part of the normal document, though. Content in the shadow DOM is in a different document. Any JavaScript on your page that starts with document., like

const allImages = document.querySelectorAll('img');

… will not pick up nodes in the shadow DOM.

I made us an example of how to attach a shadow (in 'open' mode), what will affect the content (JavaScript targetting the shadow "root", CSS in the shadow), and what won't affect it (CSS styles on the page, JavaScript targetting the main document).

Notice also that, in 'open' mode, we can inspect the shadow DOM.

Shadow DOM accessible via Chrome dev tools

At this point, you might be thinking, "Finally! A way to hide the DOM from those stupid users!", but… "closing" your shadow DOMs doesn't really work Opens in a new window.

Promotional photo for 'What We Do in the Shadows'

So if the shadow DOM just "encapsulates" (creates a sandbox for) some elements and properties, what do we use it for?

Well, it happens to come in really handy when we're using <template> and <slot>.

Using <template> and <slot>

<template> and <slot> let you create re-usable, templated HTML that can have different content every time you use it. It gets re-used by adding it as a shadow document. Sounds handy, right? Let's take a look at how we accomplish this.

Step 1: In a <template> element, create some HTML that you'd like to re-use on the page.

<template id="theTemplate">
    <p>This is the default 
    instance of the template.</p>
</template>

Step 2: Any content that might change when you put the element on the page should be wrapped in a <slot> element.

<template id="theTemplate">
    <p>This is the <slot>default 
    instance</slot> of the template.</p>
</template>

Step 3: Give each <slot> element a unique name attribute.

<template id="theTemplate">
    <p>This is the <slot name="a-number">default 
    instance</slot> of the template.</p>
</template>

Step 4: Wherever you'd like to apply the templated content, add some sort of container element. To make things easy, make sure all these containers have a shared attribute in common, like a class, or maybe a data-attribute, so that you can target all of them at the same time with JavaScript.

<div class="replace-inside">
     
</div>
<div class="replace-inside">
     
</div>
 
<template id="theTemplate">
    <p>This is the <slot name="a-number">default 
    instance</slot> of the template.</p>
</template>

Step 5: In your containers, add the content that will change from container to container. Wrap it in an element (a <span> might be a good choice), and then give that element a slot="" attribute that matches up with the name="" attribute of the <slot> you'd like to put it in.

<div class="replace-inside">
    <span slot="a-number">first copy</span>
</div>
<div class="replace-inside">
    <span slot="a-number">second copy</span>
</div>
 
<template id="theTemplate">
    <p>This is the <slot name="a-number">default 
    instance</slot> of the template.</p>
</template>

Step 6: Using JavaScript, attach a shadow to each container, and clone the template.

<div class="replace-inside">
    <span slot="a-number">first copy</span>
</div>
<div class="replace-inside">
    <span slot="a-number">second copy</span>
</div>
 
<template id="theTemplate">
    <p>This is the <slot name="a-number">default 
    instance</slot> of the template.</p>
</template>
 
<script>
const ctnrs = document.querySelectorAll(".replace-inside");
const myTemplate = document.getElementById("theTemplate");
const myTemplateContent = myTemplate.content;
 
ctnrs.forEach(replace => {
  const shadowRoot = replace
    .attachShadow({ mode: "open" })
    .appendChild(myTemplateContent.cloneNode(true));
});
</script>

I've created another example below, a little more complex, but same idea.

In this next example, we see how we can use default content in our template's slots, so that if a copy of the template doesn't supply content for them, they have a default to fall back to. We also see how we can style the templated content by adding a style element to the template (remember - the templated content gets put in the shadow DOM, so our regular styles can't touch it).

Alright, so now we're using templates to populate the shadow dom - we're just one technology away from having learned the whole Web Component suite!

Custom Elements

Custom elements are exactly what they sound like - your very own, brand new HTML elements.

We're able to define our new elements for the browser. This is thanks to JavaScript, through the customElements property.

Two important things to note:

  1. When you name your custom element, it must have a hyphen in the name, i.e. <my-element>. This is to distinguish between custom elements and normal elements.
  2. It would be very cool if we could base our custom elements off existing elements, and add things to them, rather than starting from scratch. Chrome, Firefox and Edge have a way to do this, but stupid unfortunately Safari and Opera don't. We're going to be learning the broadly supported method today, where we build our elements up from basically nothing.

To start with a custom element, we use this JavaScript:

customElements.define('our-profile',
  class extends HTMLElement {
    constructor() {
      super();
    }
  }
);

We use customElements and say we're going to define() our new custom element.

The first argument we pass in is a name. You can call your elements anything, as long as it has a hyphen! In this case, we're going to call our new element our-profile. In our HTML, it will look like this:

<our-profile></our-profile>

The other argument in our definition is an ES6 class (remember those?)

Instead of writing our class from scratch, we're going to "extend" an existing class called HTMLElement, which is the class the DOM uses to define all HTMLElements. We're essentially saying, "this thing is the most basic possible version of a generic HTML element".

Next we call our constructor, and something called super().

In JavaScript, when you're extending a class, calling super() means "take all the stuff from the original class, and make it work here". You'll want to do this first when you're extending a class, and you don't have to do anything else with it besides call it.

At this point, we've created a custom element! Congratulations, you can totally start using it in your HTML.

<our-profile>Um, is that it?</our-profile>
<script>
customElements.define('our-profile',
  class extends HTMLElement {
    constructor() {
      super();
    }
  }
);
</script>

At this point, you've basically re-created a <span> element. Just a boring, style-less inline element that displays whatever content is inside of it, as-is.

One thing we can do is style it like we could any other element, using CSS.

<!-- In the head -->
<style>
    our-profile {
        color: green;
    }
</style>
 
<!-- In the body -->
<our-profile>Ok, now I'm green.
I still don't see the point.</our-profile>
 
<script>
customElements.define('our-profile',
  class extends HTMLElement {
    constructor() {
      super();
    }
  }
);
</script>

Where things get interesting is when we start using the shadow DOM. In our JavaScript constructor, we can attach the shadow and create elements, set styles, grab custom attributes, and set defaults:

This is all well and good, but I'm of the opinion that creating elements, content and style entirely in JavaScript is… not great. Whatever happened to the separation of concerns!

If only there was a way to create templated HTML and CSS content that we then inject into the shadow DOM of our custom components.

Wait, do you think… we couldn't possibly…

Putting it all together

Alright, let's look at an example of using all the technologies in the web components suite - shadow DOM, HTML templates & slots, and custom elements.

Notice that some content gets pulled from the template, and some is created in the custom element.

In this case, I decided that I wanted to have some flexibility about what elements I put in the slots, so the templates allow for different heading elements, or having multiple paragraph tags (added by referencing the slot attribute).

<profile-card 
  data-image="https://www.svgrepo.com/show/317513/food.svg" 
  data-alternative-text="Ice cream"
  data-link-url="https://en.wikipedia.org/wiki/Ice_cream">
  <h2 slot="title" class="card-title">Ice Cream</h2>
  <p slot="description">Boy, ice cream is just about the best, isn't it?</p>
  <p slot="description">Well, assuming you're okay with lactose.</p>
</profile-card>

On the other hand, I used JavaScript to do some deliberately less flexible, more complex operations. I wanted to pull some text directly from the image's alt text, transform it to lower case, and use it as the variable text in the CTA link at the bottom (since I like to make accessibility mandatory where I can). I also wanted to make all the links have a target attribute value of "_blank" by default.

// Add an anchor
const link = shadowRoot.appendChild(document.createElement('a'));
// Set the target to _blank
link.setAttribute("target", "_blank");
// Get the alternative text and add it, lowercased, to the link 
const linkText = document.createTextNode(this.hasAttribute('data-alternative-text') ? `Learn more about ${this.getAttribute('data-alternative-text').toLowerCase()}` : '');
link.append(linkText);

The thing about web components

The thing about web components is that they kind of "over-promise and under-deliver". They seem like they should save you work, and hassle, but end up, if you're not careful with them, creating more work and more hassle.

They're also not at all backwards-compatible, if you need to support older browsers, or even older versions of modern browsers (like Microsoft Edge from a few versions back). Also, styling them can be weird Opens in a new window, they're a kind of deliberate obfuscation (which I'm not a huge fan of), and, because they're dependent on JavaScript for rendering, they can to give you a "flash of unstyled content" (a.k.a. FOUC) (although there is a recommended fix for that Opens in a new window).

Lea Verou (a very cool developer who is currently helping to work on the CSS spec) has a good post called "The Failed Promise of Web Components Opens in a new window" that goes into what they've promised and what they've failed to deliver, but also what they might deliver in the future.

All this being said, there's a few things to consider:

Web Components are definitely an active technology - for example, Github uses web components widely Opens in a new window.

I've heard people say the shadow DOM isn't accessible. That's just not true Opens in a new window. In fact, I think, if done right, web components could actually enforce accessibility, especially if you can build out more complicated things like accordions and drawer menus with accessibility built-in. Here's a really nice look at how to built accessible web components by Erik Kroes Opens in a new window.

Mostly, though, I think learning web components gives us a deeper model for understanding those flashy new frameworks. I'm a big fan of the idea that we should use native technologies by default, and use abstractions (like React, Vue, Svelte, et al.) only when they actually make our lives easier, and make our websites better.