Web Components are a set of standardized APIs that allow developers to create reusable custom HTML elements, encapsulating functionality and styling to ensure they behave consistently across web applications. They enable the development of modular, maintainable, and reusable UI elements.
Web Components were first introduced by Google in 2011. The concept originated from the need to make web development more modular, maintainable, and reusable. Over the years, developers struggled with reusing components across different projects due to global style leakage, JavaScript collisions, and inconsistent rendering. Web Components aimed to solve these problems by encapsulating structure, style, and behavior within custom HTML elements.
Web Components were introduced to address the limitations of traditional web development, where HTML, CSS, and JavaScript were scattered across the global scope, leading to:
While Web Components have been slower to gain traction due to competing solutions (like React, Angular, and Vue.js), many developers are embracing Web Components due to:
The future of Web Components looks promising as more frameworks integrate them. Some trends suggest:
Web Components are built on four key technologies:
Custom Elements allow you to define new types of DOM elements. There are two types of custom elements:
<my-button>
).<button is="my-button">
).Example:
class MyButton extends HTMLElement {
constructor() {
super();
this.innerHTML = `<button>Click me</button>`;
}
}
customElements.define("my-button", MyButton);
The Shadow DOM provides encapsulation for Web Components. Elements inside the shadow DOM do not affect the global DOM and are isolated in their own scope.
Example:
class MyComponent extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: "open" });
shadow.innerHTML = `<style>p { color: red; }</style><p>Hello World</p>`;
}
}
customElements.define("my-component", MyComponent);
The <template>
element allows you to define HTML content that can be reused by cloning it into a document.
Example:
<template id="my-template">
<p>This is a template content!</p>
</template>
In JavaScript, you can access and clone the template:
const template = document.getElementById("my-template");
const clone = document.importNode(template.content, true);
document.body.appendChild(clone);
JavaScript ES Modules enable you to export and import JavaScript functionality across files, promoting modularity.
Example:
export class MyButton extends HTMLElement {
/*...*/
}
import { MyButton } from "./MyButton.js";
While Web Components can be seen as a lightweight alternative to frameworks like React or Angular, they are often used in conjunction with these frameworks. Frameworks provide more powerful state management, routing, and ecosystem tools.
Although Web Components are framework-agnostic, integrating them into a React project involves a few steps:
ref
to attach the Web Component to a React component.Example:
import React, { useRef, useEffect } from "react";
function MyComponent() {
const myRef = useRef();
useEffect(() => {
myRef.current.addEventListener("customEvent", handleEvent);
return () => {
myRef.current.removeEventListener("customEvent", handleEvent);
};
}, []);
function handleEvent(e) {
console.log(e.detail);
}
return <my-button ref={myRef}></my-button>;
}
export default MyComponent;
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Web Components Example</title>
</head>
<body>
<my-component></my-component>
<script src="./myComponent.js" type="module"></script>
</body>
</html>
class MyComponent extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: "open" });
shadow.innerHTML = `
<style>
p { color: red; }
</style>
<p>Hello from Web Component!</p>
`;
}
}
customElements.define("my-component", MyComponent);
# Using Custom Elements
One of the key features of Web Components is the ability to create custom elements. These are HTML elements whose behavior is defined by the web developer, extending the set of elements available in the browser.
This article introduces custom elements and walks through some examples.
## Types of Custom Elements
There are two types of custom elements:
- **Customized built-in elements**: Inherit from standard HTML elements such as `HTMLImageElement` or `HTMLParagraphElement`. Their implementation extends the behavior of select instances of the standard element.
_Note_: See the `is` attribute reference for caveats on the implementation of custom built-in elements.
- **Autonomous custom elements**: Inherit from the base `HTMLElement` class. You have to implement their behavior from scratch.
## Implementing a Custom Element
A custom element is implemented as a class that extends `HTMLElement` (for autonomous elements) or the interface you want to customize (for customized built-in elements).
### Example: Minimal Custom Element (Customized `<p>`)
```js
class WordCount extends HTMLParagraphElement {
constructor() {
super();
}
// Element functionality written in here
}
```
class PopupInfo extends HTMLElement {
constructor() {
super();
}
// Element functionality written in here
}
In the class constructor, set up initial state, default values, and register event listeners. Avoid inspecting the element’s attributes or children, or adding new attributes or children at this stage.
Once your custom element is registered, the browser will call certain methods of your class when interacting with your custom element. These methods are called lifecycle callbacks.
connectedCallback()
: Called each time the element is added to the document.disconnectedCallback()
: Called each time the element is removed from the document.adoptedCallback()
: Called each time the element is moved to a new document.attributeChangedCallback()
: Called when attributes are changed, added, removed, or replaced.class MyCustomElement extends HTMLElement {
static observedAttributes = ["color", "size"];
constructor() {
super();
}
connectedCallback() {
console.log("Custom element added to page.");
}
disconnectedCallback() {
console.log("Custom element removed from page.");
}
adoptedCallback() {
console.log("Custom element moved to new page.");
}
attributeChangedCallback(name, oldValue, newValue) {
console.log(`Attribute ${name} has changed.`);
}
}
customElements.define("my-custom-element", MyCustomElement);
To make a custom element available, call the define()
method of window.customElements
.
customElements.define("word-count", WordCount, { extends: "p" });
customElements.define("popup-info", PopupInfo);
<p is="word-count"></p>
<popup-info>
<!-- Content of the element -->
</popup-info>
Custom elements can use HTML attributes to configure behavior. You can observe attribute changes by implementing observedAttributes
and attributeChangedCallback()
.
class MyCustomElement extends HTMLElement {
static observedAttributes = ["size"];
constructor() {
super();
}
attributeChangedCallback(name, oldValue, newValue) {
console.log(
`Attribute ${name} has changed from ${oldValue} to ${newValue}.`
);
}
}
customElements.define("my-custom-element", MyCustomElement);
<my-custom-element size="100"></my-custom-element>
Autonomous custom elements allow you to define states and select them using the :state()
pseudo-class function.
class MyCustomElement extends HTMLElement {
constructor() {
super();
this._internals = this.attachInternals();
}
get collapsed() {
return this._internals.states.has("hidden");
}
set collapsed(flag) {
if (flag) {
this._internals.states.add("hidden");
} else {
this._internals.states.delete("hidden");
}
}
}
customElements.define("my-custom-element", MyCustomElement);
my-custom-element {
border: dashed red;
}
my-custom-element:state(hidden) {
border: none;
}
<popup-info>
)<popup-info
img="img/alt.png"
data-text="Your card validation code (CVC) is the last 3 or 4 numbers on the back of your card."
>
</popup-info>
class PopupInfo extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
const shadow = this.attachShadow({ mode: "open" });
const wrapper = document.createElement("span");
wrapper.setAttribute("class", "wrapper");
const icon = document.createElement("span");
icon.setAttribute("class", "icon");
icon.setAttribute("tabindex", 0);
const info = document.createElement("span");
info.setAttribute("class", "info");
const text = this.getAttribute("data-text");
info.textContent = text;
const imgUrl = this.hasAttribute("img")
? this.getAttribute("img")
: "img/default.png";
const img = document.createElement("img");
img.src = imgUrl;
icon.appendChild(img);
const linkElem = document.createElement("link");
linkElem.setAttribute("rel", "stylesheet");
linkElem.setAttribute("href", "style.css");
shadow.appendChild(linkElem);
shadow.appendChild(wrapper);
wrapper.appendChild(icon);
wrapper.appendChild(info);
}
}
An important aspect of custom elements is encapsulation, because a custom element, by definition, is a piece of reusable functionality that might be dropped into any web page and is expected to work. So it’s important that code running in the page should not be able to accidentally break a custom element by modifying its internal implementation. Shadow DOM enables you to attach a DOM tree to an element and have the internals of this tree hidden from JavaScript and CSS running in the page.
This article covers the basics of using the Shadow DOM.
This article assumes you are already familiar with the concept of the DOM (Document Object Model)—a tree-like structure of connected nodes that represents the different elements and strings of text appearing in a markup document (usually an HTML document in the case of web documents). As an example, consider the following HTML fragment:
html
Here we will add a link to the Mozilla homepage
This fragment produces the following DOM structure (excluding whitespace-only text nodes):
META
charset="utf-8"
#text
: DOM exampleIMG
src="dinosaur.png"
alt="A red Tyrannosaurus Rex."
#text
: Here we will add a link to thehref="https://www.mozilla.org/"
#text
: Mozilla homepageShadow DOM allows hidden DOM trees to be attached to elements in the regular DOM tree—this shadow DOM tree starts with a shadow root, underneath which you can attach any element, in the same way as the normal DOM.
There are some bits of Shadow DOM terminology to be aware of:
You can affect the nodes in the shadow DOM in exactly the same way as non-shadow nodes—for example, appending children or setting attributes, styling individual nodes using element.style.foo
, or adding style to the entire shadow DOM tree inside a <style>
element. The difference is that none of the code inside a shadow DOM can affect anything outside it, allowing for handy encapsulation.
Before shadow DOM was made available to web developers, browsers were already using it to encapsulate the inner structure of an element. Think, for example, of a <video>
element with the default browser controls exposed. All you see in the DOM is the <video>
element, but it contains a series of buttons and other controls inside its shadow DOM. The shadow DOM spec enables you to manipulate the shadow DOM of your own custom elements.
The shadow tree and <slot>
elements inherit the dir
and lang
attributes from their shadow host.
The following page contains two elements: a <div>
element with an id
of "host"
, and a <span>
element containing some text:
<div id="host"></div>
<span>I'm not in the shadow DOM</span>
We’re going to use the “host” element as the shadow host. We call attachShadow()
on the host to create the shadow DOM, and can then add nodes to the shadow DOM just like we would to the main DOM. In this example, we add a single <span>
element:
const host = document.querySelector("#host");
const shadow = host.attachShadow({ mode: "open" });
const span = document.createElement("span");
span.textContent = "I'm in the shadow DOM";
shadow.appendChild(span);
The result looks like this:
Creating a shadow DOM via JavaScript API might be a good option for client-side rendered applications. For other applications, a server-side rendered UI might have better performance and, therefore, a better user experience. In such cases, you can use the <template>
element to declaratively define the shadow DOM. The key to this behavior is the enumerated shadowrootmode
attribute, which can be set to either open
or closed
, the same values as the mode
option of the attachShadow()
method.
<div id="host">
<template shadowrootmode="open">
<span>I'm in the shadow DOM</span>
</template>
</div>
Note: By default, contents of
<template>
are not displayed. In this case, because theshadowrootmode="open"
was included, the shadow root is rendered. In supporting browsers, the visible contents within that shadow root are displayed.
After the browser parses the HTML, it replaces the <template>
element with its content wrapped in a shadow root that’s attached to the parent element, the <div id="host">
in our example. The resulting DOM tree looks like this (there’s no <template>
element in the DOM tree):
id="host"
#shadow-root
#text
: I’m in the shadow DOMSo far, this might not look like much. But let’s see what happens if code running in the page tries to access elements in the shadow DOM.
This page is just like the last one, except we’ve added two <button>
elements.
<div id="host"></div>
<span>I'm not in the shadow DOM</span>
<br />
<button id="upper" type="button">Uppercase span elements</button>
<button id="reload" type="button">Reload</button>
Clicking the “Uppercase span elements” button finds all <span>
elements in the page and changes their text to uppercase. Clicking the “Reload” button just reloads the page, so you can try again.
const host = document.querySelector("#host");
const shadow = host.attachShadow({ mode: "open" });
const span = document.createElement("span");
span.textContent = "I'm in the shadow DOM";
shadow.appendChild(span);
const upper = document.querySelector("button#upper");
upper.addEventListener("click", () => {
const spans = Array.from(document.querySelectorAll("span"));
for (const span of spans) {
span.textContent = span.textContent.toUpperCase();
}
});
const reload = document.querySelector("#reload");
reload.addEventListener("click", () => document.location.reload());
If you click “Uppercase span elements,” you’ll see that document.querySelectorAll()
doesn’t find the elements in our shadow DOM: they are effectively hidden from JavaScript in the page.
Element.shadowRoot
and the mode
OptionIn the example above, we pass an argument { mode: "open" }
to attachShadow()
. With mode set to "open"
, the JavaScript in the page is able to access the internals of your shadow DOM through the shadowRoot
property of the shadow host.
In this example, as before, the HTML contains the shadow host, a <span>
element in the main DOM tree, and two buttons:
<div id="host"></div>
<span>I'm not in the shadow DOM</span>
<br />
<button id="upper" type="button">Uppercase shadow DOM span elements</button>
<button id="reload" type="button">Reload</button>
This time the “Uppercase” button uses shadowRoot
to find the <span>
elements in the DOM:
const host = document.querySelector("#host");
const shadow = host.attachShadow({ mode: "open" });
const span = document.createElement("span");
span.textContent = "I'm in the shadow DOM";
shadow.appendChild(span);
const upper = document.querySelector("button#upper");
upper.addEventListener("click", () => {
const spans = Array.from(host.shadowRoot.querySelectorAll("span"));
for (const span of spans) {
span.textContent = span.textContent.toUpperCase();
}
});
const reload = document.querySelector("#reload");
reload.addEventListener("click", () => document.location.reload());
This time, the JavaScript running in the page can access the shadow DOM internals:
The { mode: "open" }
argument gives the page a way to break the encapsulation of your shadow DOM. If you don’t want to give the page this ability, pass { mode: "closed" }
instead, and then shadowRoot
returns null
.
However, you should not consider this a strong security mechanism, because there are ways it can be evaded, for example by browser extensions running in the page. It’s more of an indication that the page should not access the internals of your shadow DOM tree.
In this version of the page, the HTML is the same as the original:
<div id="host"></div>
<span>I'm not in the shadow DOM</span>
In the JavaScript, we create the shadow DOM:
const host = document.querySelector("#host");
const shadow = host.attachShadow({ mode: "open" });
const span = document.createElement("span");
span.textContent = "I'm in the shadow DOM";
shadow.appendChild(span);
This time, we’ll have some CSS targeting <span>
elements in the page:
span {
color: blue;
border: 1px solid black;
}
The page CSS does not affect nodes inside the shadow DOM:
In this section, we’ll look at two different ways to apply styles inside a shadow DOM tree:
CSSStyleSheet
object and attaching it to the shadow root.<style>
element in a <template>
element’s declaration.In both cases, the styles defined in the shadow DOM tree are scoped to that tree, so just as page styles don’t affect elements in the shadow DOM, shadow DOM styles don’t affect elements in the rest of the page.
To style page elements in the shadow DOM with constructable stylesheets, we can:
CSSStyleSheet
object.CSSStyleSheet.replace()
or CSSStyleSheet.replaceSync()
.ShadowRoot.adoptedStyleSheets
.Rules defined in the CSSStyleSheet
will be scoped to the shadow DOM tree, as well as any other DOM trees to which we have assigned it.
Here, again, is the HTML containing our host and a <span>
:
<div id="host"></div>
<span>I'm not in the shadow DOM</span>
This time we will create the shadow DOM and assign a CSSStyleSheet
object to it:
const sheet = new CSSStyleSheet();
sheet.replaceSync("span { color: red; border: 2px dotted black; }");
const host = document.querySelector("#host");
const shadow = host.attachShadow({ mode: "open" });
shadow.adoptedStyleSheets = [sheet];
const span = document.createElement("span");
span.textContent = "I'm in the shadow DOM";
shadow.appendChild(span);
The styles defined in the shadow DOM tree are not applied in the rest of the page:
<style>
Elements in <template>
DeclarationsAn alternative to constructing CSSStyleSheet
objects is to include a <style>
element inside the <template>
element used to define a web component.
In this case, the HTML includes the <template>
declaration:
<template id="my-element">
<style>
span {
color: red;
border: 2px dotted black;
}
</style>
<span>I'm in the shadow DOM</span>
</template>
<div id="host"></div>
<span>I'm not in the shadow DOM</span>
In the JavaScript, we will create the shadow DOM and add the content of the <template>
to it:
const host = document.querySelector("#host");
const shadow = host.attachShadow({ mode: "open" });
const template = document.getElementById("my-element");
shadow.appendChild(template.content);
Again, the styles defined in the <template>
are applied only within the shadow DOM tree, and not in the rest of the page:
Which of these options to use is dependent on your application and personal preference.
Programmatic Approach: Creating a CSSStyleSheet
and assigning it to the shadow root using adoptedStyleSheets
allows you to create a single stylesheet and share it among many DOM trees. For example, a component library could create a single stylesheet and then share it among all the custom elements belonging to that library. The browser will parse that stylesheet once. Also, you can make dynamic changes to the stylesheet and have them propagate to all components that use the sheet.
Declarative Approach: Attaching a <style>
element is great if you want to be declarative, have few styles, and don’t need to share styles across different components.
Without the encapsulation provided by shadow DOM, custom elements would be impossibly fragile. It would be too easy for a page to accidentally break a custom element’s behavior or layout by running some page JavaScript or CSS. As a custom element developer, you’d never know whether the selectors applicable inside your custom element conflicted with those that applied in a page that chose to use your custom element.
Custom elements are implemented as a class which extends either the base HTMLElement
or a built-in HTML element such as HTMLParagraphElement
. Typically, the custom element itself is a shadow host, and the element creates multiple elements under that root to provide the internal implementation of the element.
The example below creates a <filled-circle>
custom element that just renders a circle filled with a solid color.
class FilledCircle extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
// Create a shadow root
// The custom element itself is the shadow host
const shadow = this.attachShadow({ mode: "open" });
// Create the internal implementation
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
const circle = document.createElementNS(
"http://www.w3.org/2000/svg",
"circle"
);
circle.setAttribute("cx", "50");
circle.setAttribute("cy", "50");
circle.setAttribute("r", "50");
circle.setAttribute("fill", this.getAttribute("color"));
svg.appendChild(circle);
shadow.appendChild(svg);
}
}
customElements.define("filled-circle", FilledCircle);
<filled-circle color="blue"></filled-circle>
Using templates and slots
This section will guide you through utilizing the <template>
and <slot>
elements to create dynamic, reusable templates that can populate the shadow DOM of a web component.
When you need to reuse the same HTML structure multiple times on a web page, templates are a great solution. The <template>
element allows you to define markup that is not rendered in the DOM but can be used later with JavaScript.
For example:
<template id="custom-paragraph">
<p>My paragraph</p>
</template>
To make this appear in the DOM, you would reference it in JavaScript like this:
let template = document.getElementById("custom-paragraph");
let templateContent = template.content;
document.body.appendChild(templateContent);
This basic example illustrates how you can dynamically append a template to your page.
Templates can be combined with web components for even more flexibility. Let’s define a custom web component that uses our template in its shadow DOM:
customElements.define(
"my-paragraph",
class extends HTMLElement {
constructor() {
super();
let template = document.getElementById("custom-paragraph");
let templateContent = template.content;
const shadowRoot = this.attachShadow({ mode: "open" });
shadowRoot.appendChild(templateContent.cloneNode(true));
}
}
);
Here, we clone the template content and append it to the shadow DOM. We can also include styles in the template that will only apply within the shadow DOM:
<template id="custom-paragraph">
<style>
p {
color: white;
background-color: #666;
padding: 5px;
}
</style>
<p>My paragraph</p>
</template>
Now, when you use <my-paragraph>
in your HTML:
<my-paragraph></my-paragraph>
The component will render with the custom styles encapsulated in its shadow DOM.
While this approach works, it’s limited in flexibility. You can use the <slot>
element to make the component more dynamic. Slots act as placeholders that can be filled with any content when the component is used.
For example, you can update the template to include a slot:
<p><slot name="my-text">My default text</slot></p>
If no content is provided for the slot, it will fall back to “My default text.” To specify content for the slot, use the slot
attribute:
<my-paragraph>
<span slot="my-text">Let's have some different text!</span>
</my-paragraph>
Or even more complex content:
<my-paragraph>
<ul slot="my-text">
<li>Let's have some different text!</li>
<li>In a list!</li>
</ul>
</my-paragraph>
Let’s now create a more complex web component using <template>
and <slot>
. The following example demonstrates a <element-details>
component with multiple named slots:
First, define a template with named slots:
<template id="element-details-template">
<style>
/* Add some styles for the component */
</style>
<details>
<summary>
<span>
<code class="name">
<<slot name="element-name">NEED NAME</slot>>
</code>
<span class="desc">
<slot name="description">NEED DESCRIPTION</slot>
</span>
</span>
</summary>
<div class="attributes">
<h4><span>Attributes</span></h4>
<slot name="attributes"><p>None</p></slot>
</div>
</details>
<hr />
</template>
This template includes styles and slots for an element name, description, and attributes.
Next, define the <element-details>
custom element that will use the template:
customElements.define(
"element-details",
class extends HTMLElement {
constructor() {
super();
const template = document.getElementById(
"element-details-template"
).content;
const shadowRoot = this.attachShadow({ mode: "open" });
shadowRoot.appendChild(template.cloneNode(true));
}
}
);
Finally, you can use the <element-details>
element in your HTML:
<element-details>
<span slot="element-name">slot</span>
<span slot="description">A placeholder inside a web component.</span>
<dl slot="attributes">
<dt>name</dt>
<dd>The name of the slot.</dd>
</dl>
</element-details>
<element-details>
<span slot="element-name">template</span>
<span slot="description">A mechanism for holding client-side content.</span>
</element-details>
In this example, the first <element-details>
element fills all three slots (name, description, and attributes), while the second only fills the name and description, leaving the attributes slot with its default content.
Lastly, you can style the <dl>
, <dt>
, and <dd>
elements for better presentation:
dl {
margin-left: 6px;
}
dt {
color: #217ac0;
font-family: Consolas, "Liberation Mono", Courier;
font-size: 110%;
font-weight: bold;
}
dd {
margin-left: 16px;
}