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
- Shadow DOM
- HTML Templates
- Slots
- ECMAScript Modules (ESM) & Import maps
- A glimpse into the future
- Conclusion
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 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 . 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
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. ). 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.
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
element and attach it to the host element (the
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 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
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:
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 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:
Hello World!
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:
Hello World! Hello World!
Named slots on the other hand, allow us to project different kinds of contents into specific slots:
Hello
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:
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:
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:
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):
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 ).
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: