Learn about the benefits of using Web Components, how they work, and how to get started.
With Web Components (hereafter referred to as Components), developers can create their own HTML elements. In this guide, you will learn everything there is to know about the components. We'll start with what the components are, what their benefits are, and what they are made of.
After that we'll start building the components, first using HTML templates and the shadow DOM interface, then dive into the topic a bit and see how to create a customized build-in element.
What are components?
Developers love components (here we mean the implementation of the "Module" design pattern). This is a great way to define a block of code that can be used anytime, anywhere. Over the years, several more or less successful attempts have been made to put this idea into practice.
Mozilla's XML Binding Language and Microsoft's HTML Component specification for Internet Explorer 5 were around 20 years ago. Unfortunately, both implementations were very complex and failed to interest the manufacturers of other browsers, and therefore were soon forgotten. Despite this, it was they who laid the foundations of what we have in this area today.
JavaScript frameworks like React, Vue, and Angular take a similar approach. One of the main reasons for their success is the ability to encapsulate the general logic of the application in some patterns that easily move from one form to another.
While these frameworks improve the development experience, everything comes at a price. Language features such as JSX need to be compiled, and most frameworks use a JavaScript engine to manage their abstractions. Is there another approach to solving the problem of dividing code into components? The answer is web components.
4 pillars of components
Components consist of three APIs - custom elements, HTML templates and shadow DOM, as well as their underlying JavaScript modules (ES6 modules). Using the tools provided by these interfaces, you can create custom HTML elements that behave like their native counterparts.
Components are used in the same way as regular HTML elements. They can be customized using attributes, retrieved using JavaScript, styled using CSS. The main thing is to notify the browser that they exist.
This allows components to interact with other frameworks and libraries. By using the same communication mechanism as regular elements, they can be used by any existing framework, as well as tools that will appear in the future.
It should also be noted that the components comply with web standards. The web is based on the idea of backward compatibility. This means that the components created today will work great for a long time.
Let's take a look at each specification separately.
1. Custom elements
Key features:
- Defining Element Behavior
- Reacting to Attribute Changes
- Extending existing elements
Often when people talk about components, they mean the interface of custom elements.
This API allows you to extend elements by defining their behavior when added, updated, and removed.
class ExampleElement extends HTMLElement {
static get observedAttributes() {
return [...]
}
attributeChangedCallback(name, oldValue, newValue) {}
connectedCallback() {}
}
customElements.define('example-element', ExampleElement)
Each custom element has a similar structure. It extends the functionality of the existing HTMLElements class.
Inside a custom element, there are several methods called reactions that are responsible for handling a particular change to an element. For example connectedCallback is called when an item is added to the page. This is similar to the lifecycle stages used in frameworks (componentDidMount in React, mounted in Vue).
Changing the attributes of an element entails a change in its behavior. When an update occurs, the attributeChangedCallback is called containing information about the change. This only happens for the attributes specified in the array returned by observedAttributes.
The element must be defined before the browser can use it. The "define" method takes two arguments - the name of the tag and its class. All tags must contain the "-" character to avoid conflicts with existing and future native elements.
<example-element>Content</example-element>
The element can be used like a normal HTML tag. When such an element is found, the browser associates its behavior with the specified class. This process is called "upgrading".
There are two types of items - “autonomous” and “customized build-in”. So far, we have looked at stand-alone items. These are elements that are not related to existing HTML elements. Like the div and span tags, which have no specific semantic meaning.
Custom inline elements - as their name suggests - extend the functionality of existing HTML elements. They inherit the semantic behavior of these elements and can change it. For example, if the “input” element has been customized, it will still remain an input field and part of the form when it is submitted.
class CustomInput extends HTMLInputElement {}
customElements.define('custom-input', CustomInput, { extends: 'input' })
The custom inline element class extends the custom element class. When defining an inline element, the expandable element is passed as the third argument.
<input is="custom-input" />
The use of the tag is also slightly different. Instead of a new tag, the existing one is used, specifying the special "is" extension attribute. When the browser encounters this attribute, it knows it is dealing with a custom element and updates it accordingly.
While standalone elements are supported by most modern browsers, custom inline elements are only supported by Chrome and Firefox. When used in a browser that does not support them, they will be treated like regular HTML elements, so by and large they are safe to use even in those browsers.
2. HTML templates
- Creation of ready-made structures
- Are not displayed on the page before the call
- Contain HTML, CSS and JS
Historically, client-side templating has involved concatenating strings in JavaScript or using libraries like Handlebars to parse blocks of custom markup. Recently, the specification has a 'template' tag that can contain whatever we want to use.
<template id="tweet">
<div class="tweet">
<span class="message"></span>
Written by @
<span class="username"></span>
</div>
</template>
By itself, it does not affect the page in any way, i.e. it is not parsed by the engine, requests for resources (audio, video) are not sent. JavaScript cannot access it, and for browsers it is an empty element.
const template = document.getElementById('tweet')
const node = document.importNode(template.content, true)
document.body.append(node)
First we get the "template" element. The "importNode" method creates a copy of its contents, the second argument (true) means deep copy. Finally, we add it to the page like any other element.
Templates can contain anything that normal HTML can contain, including CSS and JavaScript. When an element is added to the page, styles will be applied to it and scripts will be launched. Remember that styles and scripts are global, which means they may overwrite other styles and values used by scripts.
The templates are not limited to this. They appear in all their glory when used with other parts of the components, in particular the shadow DOM.
3. Shadow DOM
- Avoids style conflicts
- Coming up with names (of classes, for example) becomes easier
- Encapsulating implementation logic
The Document Object Model (DOM) is how the browser interprets the structure of the page. By reading the markup, the browser determines which elements contain what content and, based on this, makes a decision about what should be displayed on the page. When using document.getElemetById (), for example, the browser accesses the DOM to find the element it needs.
For a page layout, this is fine, but what about the details hidden inside the element? For example, a page shouldn't care what interface is contained within the "video" element. This is where shadow DOM comes in handy.
<div id="shadow-root"></div>
<script>
const host = document.getElementById('shadow-root')
const shadow = host.attachShadow({ mode: 'open' })
</script>
The shadow DOM is created when applied to an element. Any content can be added to the shadow DOM, just like a regular (light) DOM. The shadow DOM is not affected by what happens outside, i.e. outside of it. Plain DOM also cannot access shadow directly. This means that in the shadow DOM we can use any class names, styles and scripts and not worry about possible conflicts.
The best results are obtained using shadow DOM in conjunction with custom elements. Thanks to the shadow DOM, when a component is reused, its styles and structure do not affect other elements on the page in any way.
ES and HTML modules
- Adding as needed
- No pre-generation required
- Everything is stored in one place
While the previous three specifications have come a long way in their development, how they are packaged and reused remains a subject of intense debate.
The HTML Imports specification defines how HTML documents, as well as CSS and JavaScript, are exported and imported. This would allow custom elements, along with templates and shadow DOM, to be located elsewhere and used as needed.
However, Firefox refused to implement this specification in its browser and offered a different way based on JavaScript modules.
export class ExampleElement external HTMLElement {}
import { ExampleElement } from 'ExampleElement.js'
Modules have their own namespace by default, i.e. their content is not global. Exported variables, functions and classes can be imported anywhere and anytime and used as local resources.
This works great for components. Custom elements containing template and shadow DOM can be exported from one file and used in another.
import { ExampleElement } from 'ExampleElement.html'
Microsoft has put forward a proposal to extend the JavaScript modules specification with HTML export / import. This will allow you to create components using declarative and semantic HTML. This feature is coming to Chrome and Edge soon.
Creating your own component
While there are a lot of things about components that you might find complex, creating and using a simple component only takes a few lines of code. Let's look at some examples.
Components let you display user comments using HTML templating and shadow DOM interfaces.
Let's create a component to display user comments using HTML templates and shadow DOM.
1. Creating a template
The component needs a template to copy before generating the markup. The template can be located anywhere on the page, the custom element class can access it through the ID.
Add the “template” element to the page. Any styles defined on this element will only affect it.
<template id="user-comment-template">
<style>
...
</style>
</template>
2. Adding markup
Besides styles, a component can contain a layout (structure). For this purpose, the "div" element is used.
Dynamic content is passed through slots. Add slots for the avatar, name and user message with the appropriate "name" attributes:
<div class="container">
<div class="avatar-container">
<slot name="avatar"></slot>
</div>
<div class="comment">
<slot name="username"></slot>
<slot name="comment"></slot>
</div>
</div>
Default Slot Content
The default content will be displayed when there is no information passed to the slot.
Data passed to the slot overwrites the data in the template. If no information is passed to the slot, default content is displayed.
In this case, if the username was not transferred, the message "No name" is displayed in its place:
<slot name="username">
<span class="unknown">No name</span>
</slot>
3. Creating a class
Creating a custom element begins by extending the "HTMLElement" class. Part of the setup process is to create a shadow root to render the element's content. We open it for access at the next stage.
Finally, we inform the browser about the new UserComment class.
class UserComment extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: 'open' })
}
}
customElements.define('user-comment', UserComment)
4. Applying shadow content
When the browser encounters the "user-comment" element, it looks at the shadow root node to retrieve its content. The second argument tells the browser to copy all of the content, not just the first layer (top-level elements).
We add markup to the shadow root node, which immediately updates the appearance of the component.
connectedCallback() {
const template = document.getElementById('user-comment-template')
const node = document.importNode(template.content, true)
this.shadowRoot.append(node)
}
5. Using the component
The component is now ready to use. Add the "user-comment" tag and pass the necessary information to it.
Since all slots have names, anything that is passed outside of them will be ignored. Everything inside the slots is copied exactly as passed, including styling.
<user-comment>
<img alt="" slot="avatar" src="avatar.png" />
<span slot="username">Matt Crouch</span>
<div slot="comment">This is an example of a comment</div>
</user-comment>
Extended example code:
<!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>
<style>
body {
display: grid;
place-items: center;
}
img {
width: 80px;
border-radius: 4px;
}
</style>
</head>
<body>
<template id="user-comment-template">
<div class="container">
<div class="avatar-container">
<slot name="avatar">
<slot class="unknown"></slot>
</slot>
</div>
<div class="comment">
<slot name="username">No name</slot>
<slot name="comment"></slot>
</div>
</div>
<style>
.container {
width: 320px;
clear: both;
margin-bottom: 1rem;
}
.avatar-container {
float: left;
margin-right: 1rem;
}
.comment {
height: 80px;
display: flex;
flex-direction: column;
justify-content: center;
}
.unknown {
display: block;
width: 80px;
height: 80px;
border-radius: 4px;
background: #ccc;
}
</style>
</template>
<user-comment>
<img alt="" slot="avatar" src="avatar1.jpg" />
<span slot="username">Matt Crouch</span>
<div slot="comment">Fisrt comment</div>
</user-comment>
<user-comment>
<img alt="" slot="avatar" src="avatar2.jpg" />
<!-- no username -->
<div slot="comment">Second comment</div>
</user-comment>
<user-comment>
<!-- no avatar -->
<span slot="username">John Smith</span>
<div slot="comment">Second comment</div>
</user-comment>
<script>
class UserComment extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
const template = document.getElementById("user-comment-template");
const node = document.importNode(template.content, true);
this.shadowRoot.append(node);
}
}
customElements.define("user-comment", UserComment);
</script>
</body>
</html>
Creating a custom inline element
As noted earlier, custom elements can extend existing ones. This saves time by maintaining the default behavior of the element provided by the brooder. In this section, we'll look at how you can extend the "time" element.
1. Creating a class
Built-in elements, like stand-alone ones, appear when the class is extended, but instead of the general class "HTMLElement", they extend a specific class.
In our case, this class is HTMLTimeElement - the class used by the "time" elements. It includes behavior related to the "datetime" attribute, including data format.
class RelativeTime extends HTMLTimeElement {}
2. Element definition
The element is registered by the browser using the "define" method. However, unlike a stand-alone element, when registering an inline element, the "define" method must be passed a third argument, an object with settings.
Our object will contain one key with the value of the custom element. It takes the name of the tag. In the absence of such a key, an exception will be thrown.
customElements.define('relative-time', RelativeTime, { extends: 'time' })
3. Setting the time
Since we can have several components on a page, the component must provide a method for setting the value of an element. Inside this method, the component passes a time value to the "timeago" library and sets the value returned by that library as the item value (sorry for the tautology).
Finally, we set the title attribute so that the user can see the value set on hover.
setTime() {
this.innerHTML = timeago().format(this.getAttribute('datetime'))
this.setAttribute('title', this.getAttribute('datetime'))
}
4. Connection update
The component can use the method immediately after being displayed on the page. Since inline components don't have a shadow DOM, they don't need a constructor.
connectedCAllback() {
this.setTime()
}
5. Tracking the change of attributes
If you update the time programmatically, the component will not respond. He doesn't know that he has to watch for changes in the "datetime" attribute.
Once observed attributes have been defined, attributeChangedCallback will be called whenever they change.
static get observedAttributes() {
return ['datetime']
}
attributeChangedCallback() {
this.setTime()
}
6. Adding to the page
Since our element is an extension of the native element, its implementation is slightly different. To use it, add the tag "time" to the page with a special attribute "is", the value of which is the name of the built-in element defined during registration. Browsers that don't support components will render fallback content.
<time is="relative-time" datetime="2020-09-20T12:00:00+0000">
20 2020 . 12:00
</time>
Extended example code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Web Components Another Example</title>
<!-- timeago.js -->
<script
src="https://cdnjs.cloudflare.com/ajax/libs/timeago.js/4.0.2/timeago.min.js"
integrity="sha512-SVDh1zH5N9ChofSlNAK43lcNS7lWze6DTVx1JCXH1Tmno+0/1jMpdbR8YDgDUfcUrPp1xyE53G42GFrcM0CMVg=="
crossorigin="anonymous"
></script>
<style>
body {
display: flex;
flex-direction: column;
align-items: center;
}
input,
button {
margin-bottom: 0.5rem;
}
time {
font-size: 2rem;
}
</style>
</head>
<body>
<input type="text" placeholder="2020-10-20" value="2020-08-19" />
<button>Set Time</button>
<time is="relative-time" datetime="2020-09-19">
19 2020 .
</time>
<script>
class RelativeTime extends HTMLTimeElement {
setTime() {
this.innerHTML = timeago.format(this.getAttribute("datetime"));
this.setAttribute("title", this.getAttribute("datetime"));
}
connectedCallback() {
this.setTime();
}
static get observedAttributes() {
return ["datetime"];
}
attributeChangedCallback() {
this.setTime();
}
}
customElements.define("relative-time", RelativeTime, { extends: "time" });
const button = document.querySelector("button");
const input = document.querySelector("input");
const time = document.querySelector("time");
button.onclick = () => {
const { value } = input;
time.setAttribute("datetime", value);
};
</script>
</body>
</html>
Hopefully I've helped you get a basic understanding of what web components are, what they are for, and how they are used.