Web Components (Part 2): Browser APIs

As we’ve seen in the previous article of this blog post series, Web Components are a technology-agnostic way of building appealing web applications. In this second article, we take a look at this ready-to-use suite of web standards one-by-one and explore their features.

Contents

In this text we will look at the following standards and topics:

Custom elements

With the custom elements API you can register custom HTML tags that behave like built-in HTML elements. They have attributes/properties, can emit DOM events and can be interacted with using the familiar DOM APIs (like document.querySelector etc.).

A custom element has a host element where it attaches its own DOM tree. It typically adds event listeners, implements component logic and updates its children (rendering).

Create your own custom element

To define a custom element, you can create a controller that extends HTMLElement, then register it in the global custom elements registry for a specific tag name as follows:

class MyComponent extends HTMLElement {
  constructor() {
    super(); // ⚠️ Always call the parent constructor
    // ...
  }
}

window.customElements.define(
  "my-component", // ⚠️ Must contain hyphen
  MyComponent
)

The browser now instantiates the controller for every occurrence of a <my-component> tag in the markup.

Lifecycle callbacks

The controller can implement various lifecycle callbacks for certain events such as when the element has been added to the DOM or when an attribute has changed:

class MyComponent extends HTMLElement {
  connectedCallback() {} // Added to DOM
  disconnectedCallback() {} // Removed from DOM
  adoptedCallback() {} // Moved to new document
  
  // Attribute changed
  static get observedAttributes() {
    return ['x', 'y'];
  }
  attributeChangedCallback(
    name, oldValue, newValue) {}
}

Browser support

Custom elements are supported by all major browsers since the release of Chromium-based Edge 79 in January 2020, see Can I use „Custom Elements (v1)“?.

Apart from the so called autonomous custom elements we created in our example, there has been a proposal for customized built-in elements to extend existing HTML elements like <a is="my-fancy-link">. But due to technical problems the WebKit team discovered, this spec has been rejected.

Shadow DOM

Browsers have long been using shadow DOM to encapsulate built-in elements such as the <video> tag. You can see the tag, but you can’t manipulate its internal elements, buttons and controls.

We as web developers can also use the shadow DOM API to hide the internal DOM structure and styles of a component, to avoid style clashes or leaking of styles. If you create a datepicker component for example, it should look nice no matter where it will be embedded and it should also not break the styling of the page it is embedded in. Also you want the datepicker’s internal DOM elements to be scoped, so APIs like document.querySelector won’t find them.

How does it work?

Let’s introduce some jargon. We speak about the light DOM, which is the regular DOM we see, and the shadow DOM, which is the part of the DOM that is hidden/encapsulated. You can think of it like a concert, where all you can see is the singer standing in the spotlight (light DOM), but for the whole music to be happening a band is invisibly playing in the background (shadow DOM).

Technically it works like this: a shadow root can be attached to any element in the light DOM (the shadow host). Usually the shadow host will be your custom element’s host element (e.g. <my-component>). You can then attach elements to the shadow root just as with any other DOM node. This constitutes the shadow tree which is surrounded by sort of a shadow boundary, that has special rules concerning what can pass it and how.

A diagram that shows the light DOM with the shadow host and the shadow DOM with the shadow root, that is attached to the shadow host.

Encapsulate your custom element’s internal DOM

Assume the component we’ve created previously renders some child elements:

class MyComponent extends HTMLElement {
  constructor() {
    super();

    const paragraph = document.createElement("p");
    paragraph.textContent = "Hello World!";
    this.append(paragraph);
  }
}

In this snippet we create a new <p> element and attach it to the host element (the <my-component> tag). Note that we are not using shadow DOM yet, the attached paragraph is fully accessible in the light DOM and as such is in the scope of the page’s styles. But we can change that easily:

class MyComponent extends HTMLElement {
  constructor() {
    super();

    this.attachShadow({ mode: "open" });
    
    const paragraph = document.createElement("p");
    paragraph.textContent = "Hello World!";
    this.shadowRoot.append(paragraph);
  }
}

By calling the HTMLElement’s attachShadow function, a shadow root is attached to the host element. We then attach our paragraph to the shadow root instead of the host element itself. That’s how easy it is to encapsulate the paragraph in the component’s shadow DOM.

Note that there is also a "closed" mode setting, but it is irrelevant in practice.

How to style the shadow DOM?

So now, due to the encapsulation, any styles defined in our light DOM will not apply to the shadow tree of our component. To style the elements in the shadow tree, we have to include the styles in the shadow DOM:

class MyComponent extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: "open" });
    // ...
    
    // Add style element, but you could also import a CSS file with <link>
    const style = document.createElement("style");
    style.textContent = `
      p {
        color: red;
      }
    `;
    this.shadowRoot.append(style);
  }
}

As we’ve already learned, the shadow DOM style encapsulation works in both directions. The style we defined in the shadow DOM for paragraphs is not affecting any of the <p> elements outside of the component and vice versa.

Within the shadow DOM’s CSS there are a few special selectors we can use:

/* Host element itself */
:host {}

/* Host element with "active" class */
:host(.active) {}

/* When host element has ancestor with "dark" class */
:host-context(.dark) {}

Inspection

Although the shadow DOM is encapsulated and there are restrictions on what passes the shadow boundary, it can be inspected in the browser’s Dev Tools. It is displayed as a virtual node below the host element containing the shadow tree:

A screenshot of the dev tools inspector showing the shadow root as virtual node below the host element.

Browser support

The shadow DOM API is also supported by all major browsers since the release of Chromium-based Edge 79 in January 2020, see Can I use „Shadow DOM (v1)“?.

HTML Templates

Until now we’ve created the DOM of our component programmatically, which is a bit tedious. The HTML <template> element provides a way to define markup, that is not part of the document (i.e. not rendered per se), but can be referenced with JavaScript. So we can update our component to use a template to declaratively define the shadow DOM:

<template id="my-template">
  <style>
    p {
      color: red;
    }
  </style>
  <p>Hello World!</p>
</template>

<script>
  class MyComponent extends HTMLElement {
    constructor() {
      super();
      this.attachShadow({ mode: "open" });
      
      const template = document.getElementById('my-template');
      this.shadowRoot.append(
        template.content.cloneNode(true)
      );
    }
  }
</script>

Luckily, this also works for the styles contained within the template, which will apply within the shadow DOM when the template’s contents are attached.

Browser support

HTML templates have been supported for quite some time by all major browsers – even the old Edge supported them since 2017, see Can I use „HTML templates“?.

Slots

Slots allow us to project a custom element’s content into the template. This concept is also named content projection or transclusion in some JavaScript frameworks.

The slot can be unnamed and may contain simple text content or a complex children tree:

<!-- Text node: -->
<my-component>Hello World!</my-component>

<!-- Children tree: -->
<my-component>
  <strong>Hello World!</strong>
</my-component>

<template id="my-template">
  <p>
    <slot></slot>
  </p>
</template>

Named slots on the other hand, allow us to project different kinds of contents into specific slots:

<my-component>
  <p slot="message">Hello</p>
  <img slot="image" src="img.png" />
</my-component>

<template id="my-template">
  <div class="container">
    <slot name="image"></slot>
    <slot name="message"></slot>
  </div>
</template>

But how do slots work?

An inspection with the Dev Tools reveals a bizarre picture. Somehow the slots contain (or reference) elements that live outside of the shadow root:

A screenshot of the dev tools showing the shadow DOM and the slots, refering the slotted elements in light DOM.

Graphically depicted, we see that the slotted elements actually live in the light DOM, while the slots themselves (the „portals“) live in the shadow DOM:

A diagram showing the light DOM and the shadow DOM with the shadow root attached to the shadow host and the slots (within the shadow tree) refering to the slotted children (within the light DOM).

Styling slots

The fact that slotted content lives in the light DOM has some impact on how we can style it from within the shadow DOM. We have some special CSS selectors at our disposal, but they come with the restriction that we can only style the slotted element itself, not the children of the slotted elements:

/* Style slotted element itself */
::slotted {}
::slotted([slot="image"]) {}

/*
 ⚠️ Won't work:
 style children of slotted element
*/
::slotted li {}

/*
 ⚠️ Won't work:
 style slot itself
*/
slot {}
slot[name="image"] {}

Interact with slots programmatically

Slots also provide an API to interact with them and their assigned nodes programmatically:

const slot = this.shadowRoot
  .querySelector('#slot');

const nodes = slot.assignedNodes();

slot.addEventListener(
  'slotchange',
  (event) => {
    console.log('Light DOM children changed');
  }
);

Browser support

Just like the shadow DOM API, slots are supported by all major browsers since the release of Chromium-based Edge 79 in January 2020, see Can I use „slot“?.

ECMAScript Modules (ESM)

Native JavaScript modules are a standard not strictly tied to Web Components. But they are often mentioned in the same breath, since ESM together with the Web Component standards suite, allow a web-native development of modern, interactive JavaScript apps/components without the need of a build step (i.e. bundler/transpiler) or a heavy JavaScript framework.

Import/export syntax

Basically there are named exports/imports and default exports/imports:

// Named export and import
export function add(a, b) { return a + b; }
import { add } from "./utils.js";

// Default export and import
export default function add(a, b) {}
import add from "./utils/add.js";

// Import from a URL
import uniq from "https://unpkg.com/lodash-es/uniq.js";

You may already be using this syntax when writing TypeScript or together with a module bundler. The cool thing is, that the browser now „understands“ these modules and is able to fetch and resolve the whole dependency tree.

Loading modules

One way to load JavaScript modules is declaratively via script tag:

<script type="module" src="main.js"></script>

Another is to import them imperatively via JavaScript (dynamic import):

import("/my-module.js").then((module) => {
  // ...
});

Import maps

Typically, when working with NPM and a module bundler, we can import a package previously installed with NPM like this:

import uniq from "lodash-es/uniq.js";

Apparently, when evaluated on the client, the browser does not know where to load this file from the lodash-es package, since it isn’t a relative file path nor an URL. As a solution, we can implement an import map to define how the browser will resolve a given module specifier (and make the above import statement work):

<script type="importmap">
  {
    "imports": {
      "lodash-es": "https://unpkg.com/lodash-es@4.17.21/lodash.js",
      "lodash-es/": "https://unpkg.com/lodash-es@4.17.21/"
    }
  }
</script>

Together with JavaScript modules, import maps can replace NPM and the package.json as well as the bundler completely, if we want it to.

Browser support

JavaScript modules via script tag are supported by all major browsers since the release of Firefox 60 in May 2018, see Can I use „JavaScript modules via script tag“?.

The dynamic imports are supported by all major browsers since the release of Chromium-based Edge 79 in January 2020, see Can I use „JavaScript modules: dynamic import“?.

Import maps are supported by all major browsers since the release of Safari 16.4 in March 2023, see Can I use „Import maps“?.

A glimpse into the future

There are many features we haven’t covered in this article, like event re-targeting, focus delegation, customizing of component styling with CSS custom properties or form-associated custom elements.

In addition, the Web Component standards are constantly evolving. There are proposals to improve accessibility (e.g. cross-root ARIA or default accessbility roles), templating (e.g. DOM parts and their declarative definition/binding in templates) or styling (e.g. CSS module scripts or CSS theming).

An interesting feature close to full support is the declarative shadow DOM (at the time of writing, Firefox support is still missing but may soon come), which allows us to define the shadow DOM in HTML templates and will facilitate server-side rendering (SSR) of Web Components. Also pleasing is the custom attributes proposal, which may one day allow us to create reusable behaviors that can be attached to any HTML element similar to custom elements (like <button material-ripple>Click Me</button>).

Besides the Web Components standards suite, there are many projects that try to advance the tooling around Web Components, like the Web Components DevTools or the custom-elements-manifest.

As you can see, there is a lot going on in the Web Components space. For an in-depth overview of the current standards and proposals under the Web Components umbrella, checkout the article 2023 State of Web Components by Robert Eisenberg.

Conclusion

With the Web Component APIs today’s browsers are equipped with the necessary means to build state-of-the-art frontend applications without the need for any complex JavaScript frameworks or build tools.

We’ve learned how to create custom elements that integrate seamlessly into HTML and the DOM. With the shadow DOM we’ve encapsulated the component’s internal DOM structure and its styles. To define our DOM declaratively, we’ve used HTML templates. And with native JavaScript modules, we don’t even need a package manager or a module bundler to integrate custom or third-party modules.

But still, compared to a JavaScript framework like Angular or React, there are some parts missing. That’s why many developers, including us, are working with lightweight Web Component libraries like Lit or Stencil. These libraries offer important features like data bindings in templates, reactive properties and convenient APIs – all on top of the official browser APIs we’ve seen in this article.

Coming up

In the next article of this blog post series on Web Components, we will take a look at the Lit library and how you can set it up for your project.

All articles of the series:

Kommentare sind geschlossen.