Overview
By the end of this chapter, you will be able to create custom elements to use on a web page; encapsulate the structure and styles of a custom element with the Shadow DOM; create a simple-modal using HTML templates; and create and share your own web component.
This chapter introduces the three technologies used to create web components – custom elements, the Shadow DOM, and HTML templates – and how they can be combined to make reusable components.
In the previous chapter, we learned about the techniques we can use to ensure we are writing in a well-supported standard. In this chapter, we will use many of these techniques to create web components – bits of UI that we can safely share across multiple web apps and sites.
The idea behind web components is that you, as a developer, are able to create a custom HTML element that is reusable.
To facilitate the reusability of the component, we need to be able to encapsulate the functionality or behavior of the component so that it doesn't pollute the rest of our code and, in turn, is not polluted by outside influences.
For example, we may want to create a web component that handles notifications or alert messages on a web page. To do this, we would want the colors for these messages to be consistent in all cases; perhaps we would add a red background for an error alert, a yellow background for an information alert, and green background for a successful action alert.
Let's assume that these alerts are styled with the following CSS styles:
<style>
.error {
background: red;
}
.info {
background: yellow;
}
.success {
background: green;
}
</style>
If we added these alerts to a page not completely under our control, for example, a WordPress blog or large site using a UI library, it would be easy, for example, to have another error class set that obscures text by setting a style later in the HTML document:
.error {
color: red;
}
Now, our error alert would appear as a red block with no visible text, which could be a real problem for our users. The encapsulation of a component, in this case, would solve this problem and give us more confidence in our alert component.
If you want to reuse a complex behavior or UI widget before using web components, you would have to:
This is not a great developer experience and it is prone to errors.
The great benefit of web components is that a developer can add a custom element to their web app or HTML document and everything that's needed for the component to work will be encapsulated or contained within that element. This provides a better developer experience, can lead to cleaner code and better-controlled interfaces for your components, and make the interaction between components easier.
We will look at the benefits of web components in more detail later in this chapter but first, we will look at the three technologies we need to make web components. To create web components, we will be working with a combination of three new technologies in HTML5, as follows:
We will look at each of these technologies separately, starting with custom elements, before we look at how combining them makes web components a reality for the modern web.
We've looked at a lot of the elements provided by the HTML5 standard in previous chapters; for example, the p element defines a paragraph within the body of an HTML document, the a element represents an anchor or link, and the video element defines a video source that we can embed in our HTML document.
HTML5 also tolerates non-standard elements; you could add a tag such as <something></something> in a HTML5 document and it would pass a validation check. However, this element would not have any semantic meaning for a browser. With a custom element, we can do more.
Custom elements let you create your own element types that you can then use on a web page. A custom element is a lot like one of those standard HTML elements; they use the same syntax (a name surrounded by angle brackets) and the main difference is that they are not defined in the HTML5 standard, so we have to register them to be able to use them.
To create a custom element, we will need to add it to the custom element registry, which we do with JavaScript. In JavaScript, this is defined as the CustomElementRegistry object. We can access the CustomElementRegisty object through a property called customElements, which is defined in the global window object.
Note
The global object (that is, in the case of the browser, the window object) contains the DOM document and all the browser APIs that are available in JavaScript. Whenever a JavaScript script runs in the browser, it has access to this window object.
The main method of customElements that we want to use is the define method as it allows us to register a new custom element. We call the define method with the customElements.define() JavaScript code and we can pass arguments into the method between the brackets. We need to pass the method a unique name, which is the name for the custom element, and a JavaScript class that defines its behavior.
Note
The class keyword is used to create a JavaScript object with a custom set of methods and properties. We can create an instance of a class using the new keyword, and each instance will have the methods and properties defined in the class.
A class can inherit from another class using the extends keyword. This means that the new class will inherit the methods and properties of the class it extends.
Let's look at an example:
<main-headline />
<script>
customElements.define("main-headline",
class MainHeadline extends HTMLElement {
}
);
</script>
The define method takes a string as its first argument. This is the name you wish to register your new custom element with. In this example, we have named our custom element, main-headline. Any instances of the main-headline element in the HTML document will, therefore, be registered as this custom element.
The second argument of the define method is the element's constructor. This a JavaScript class that extends HTMLElement and defines the behavior of our custom element. In our example, the behavior adds nothing to HTMLElement, but we will look at some possibilities for extending the behavior of HTMLElement and other built-in elements later in this chapter.
While a JavaScript class can have any name that is valid for a JavaScript variable – the first character must be a letter or underscore and all the other characters can be an alphanumeric character or underscore – there is a convention for class names to use a capitalized first letter and capital letters for the first letter of each word, for instance, MainHeadline.
There is an optional third argument for the define method that takes an options object. Currently, the options object has one possible property, that is, the extends property. The extends property lets us specify a built-in element from which our custom element will inherit.
We will look at extending built-in HTML elements in more detail later in this chapter.
For a custom element name to be valid, it must have a hyphen somewhere in the name. For example, main-headline is a valid custom element name, but mainheadline is not.
There is no restriction on how many hyphens appear in the name, which means, for example, sub-sub-headline is a valid custom element name as well.
If we were to provide a non-valid name to the define method it would throw an error, like so:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Naming convention</title>
</head>
<body>
<mainheadline />
<script>
customElements.define("mainheadline",
class MainHeadline extends HTMLElement {
}
);
</script>
</body>
</html>
This code would result in our browser throwing an error that we can view in the console tab of the developer tools. In Chrome, we can access the developer tools with the keyboard shortcuts Ctrl + Shift + I (PC) or Cmd + Opt + I (Mac).
For example, in the Console tab of the Chrome developer tools, we would see a message similar to the one shown in the following screenshot. The message, highlighted in red, says DOMException: Failed to execute 'define' on 'CustomElementRegistry': 'mainheadline' is not a valid custom element name:
A custom element name can only be registered once. For example, we cannot define two different main-headline elements. If you try to define a custom element when a custom element with the same name has already been defined, the browser will throw an error.
In code, this would look something like the following example, where we have called the customElements.define method twice with the name main-headline:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Naming convention</title>
</head>
<body>
<main-headline />
<script>
customElements.define("main-headline",
class MainHeadline extends HTMLElement {
}
);
customElements.define("main-headline",
class OtherMainHeadline extends HTMLElement {
}
);
</script>
</body>
</html>
In the Console tab of the Chrome developer tools, we will see a message similar to the one shown in the following screenshot. The message, highlighted in red, says DOMException: Failed to execute 'define' on 'CustomElementRegistry': this name has already been used with this registry:
If the constructor for our custom element does not extend HTMLElement, the browser will once again throw an error. For example, let's say we try and extend SVGElement instead of HTMLElement:
<sub-headline></sub-headline>
<script>
customElements.define("sub-headline",
class SubHeadline extends SVGElement {
}
);
</script>
This code would, again, result in an error being thrown, with a message similar to the one shown in the following screenshot. The message, highlighted in red, says TypeError: Illegal constructor:
The constructor can extend a subclass of HTMLElement rather than extend HTMLElement. For example, HTMLParagraphElement extends HTMLElement, and that means our constructor can extend HTMLParagraphElement rather than HTMLElement.
We will see more examples of extending a subclass of HTMLElement when we look at extending built-in elements later in this chapter.
So far, we've seen how we can define a custom element and pass it a class constructor that extends HTMLElement to define its behavior. Now, we'll try creating our own custom element.
In this exercise, we are going to create a custom element and add it to a web page. We will create a blog-headline custom element for a blog post.
Here are the steps we will follow:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Exercise 12.01: Creating a Custom Element</title>
</head>
<body>
<main class="blog-posts">
<article>
<!-- add main-heading here -->
</article>
</main>
</body>
</html>
<blog-headline />
</main>
<script>
// register blog-headline
</script>
</body>
<script>
class BlogHeadline extends HTMLElement {}
</script>
<script>
class BlogHeadline extends HTMLElement {}
window.customElements.define("blog-headline", BlogHeadline);
</script>
<script>
class BlogHeadline extends HTMLElement {
constructor() {
super();
}
}
window.customElements.define("blog-headline", BlogHeadline);
</script>
The constructor is a special method that is called when an instance of our custom element is created. We have to call the super function here as that will construct the parent class, that is, the HTMLElement class, that our BlogHeadline class extends. If we do not call super in the constructor, our BlogHeadline class will not have properly extended HTMLElement, which will cause an Illegal constructor error.
<script>
class BlogHeadline extends HTMLElement {
constructor() {
super();
this.textContent = "Headline";
}
}
window.customElements.define("blog-headline", BlogHeadline);
</script>
<article>
<blog-headline />
</article>
<article>
<blog-headline />
</article>
If you now right-click on the filename in VSCode on the left-hand side of the screen and select open in default browser, you will see the result of this code that is shown in the following image. We have created a custom element, the blog-headline element, which currently adds the word "Headline" wherever it is added to the HTML document. In the next few exercises, we will expand upon the functionality of our blog-headline element to make it more useful:
By adding text to our blog-headline element, we have had a glimpse at how we can add behavior to our custom elements. So far, this custom element doesn't do much, but we will learn about techniques for making more complex custom elements and for adding styles, behavior, and customized instances of the element to make them more reusable.
Next, we will delve deeper into how we can customize the behavior and functionality of a custom element.
Like the standard elements in HTML5, our custom elements can have content (text or child elements) and can provide an interface for modifying the element via attributes. As each custom element has a JavaScript class associated with it, we can enhance the element with a JavaScript API, where we can define properties and methods that add functionality to our custom element.
We can add text to an instance of a custom element and add attributes in the same way we would with the built-in elements. For example, we could have a custom element, the styled-text element, with some text content and options for a dark, highlight, or light theme via the theme attribute:
<styled-text theme="dark">The castle stood tall.</styled-text>
We can then refer to the attribute from within our custom element's JavaScript class using the getAttribute, setAttribute, and hasAttribute methods. In JavaScript, we are able to control the flow of our code using if/else statements and switch statements.
Note
In JavaScript, we can use the if, else if, and else statements to control which lines of code are executed. if/else statements will check a condition that can be either true or false and will execute the code it encompasses if that condition is met. For example, the if(true) statement would always run, whereas if(false) would never run. An else statement will run when the condition of a preceding if statement is not met.
To style the text according to the theme attribute, we could define the custom element styled text. Here, we use the setStyle method to change the background color and color styles of the element depending on the value of the theme attribute. The code is as follows:
Example 12.01.html
23 setStyle(theme) {
24 if (theme === "highlight") {
25 this.style.backgroundColor = "yellow";
26 this.style.color = "black";
27 }
28 else if (theme === "light") {
29 this.style.backgroundColor = "white";
30 this.style.color = "black";
31 }
32 else if (theme === "dark") {
33 this.style.backgroundColor = "black";
34 this.style.color = "white";
35 }
36 }
The full example code is available at: https://packt.live/2NMEWfn
If we apply each of the themes to the same text, we get the following output. The different theme attribute values, that is, highlight, light, and dark, each style the text differently:
In the next exercise, we will use a similar technique to that shown in the preceding example to work with multiple attributes on a custom element. We will set attributes on a blog-headline element to change the appearance of the headline.
As well as attributes with a value such as "light" or "dark" and "dog" or "cat", we can use boolean attributes. A boolean attribute is an attribute that is either present or absent from the element. You do not have to set a value for this attribute. The fact is that if it is present it is true and if it is absent it is false. As an example, we can set our blog-headline to either be a new post or not use an attribute.
For example, the following code would set the first custom element as an old post and the second element as a new post. This is done by using the newpost attribute:
<blog-headline>Old post</blog-headline>
<blog-headline newpost>New post</blog-headline>
We can put these techniques for adding attributes to a custom element and using those attributes to control the behavior of a custom element into practice with an exercise.
In this exercise, we will expand upon the functionality of the blog-headline element that we created in our previous exercise.
We will add text content to the blog-headline element so that we have different text for each blog-headline instance.
We will then modify the blog-headline with attributes:
The steps are as follows:
<head>
<meta charset="UTF-8">
<title>Exercise 12.02: Custom Element with Attributes</title>
</head>
<main class="blog-posts">
<article>
<blog-headline>Blog Post About Kittens</blog-headline>
</article>
<article>
<blog-headline>Blog Post About Puppies</blog-headline>
</article>
</main>
<main class="blog-posts">
<article>
<blog-headline type="cats">Blog Post About Kittens</blog-headline>
</article>
<article>
<blog-headline type="dogs">Blog Post About Puppies</blog-headline>
</article>
</main>
<script>
class BlogHeadline extends HTMLElement {
constructor() {
super();
}
}
window.customElements.define("blog-headline", BlogHeadline);
</script>
<script>
class BlogHeadline extends HTMLElement {
constructor() {
super();
}
get type() {
return this.getAttribute("type");
}
set type(newValue) {
this.setAttribute("type", newValue);
}
}
window.customElements.define("blog-headline", BlogHeadline);
</script>
When the type attribute is set, we want to add an icon to the headline. Depending on whether the type attribute value is dogs or cats, the icon will either be a dog or a cat. We will use the Unicode values ? and ? to create our cat and dog icons, respectively. You can try out other Unicode characters too; search online for a Unicode version of a symbol that you can copy into the code in place of the cat or dog.
class BlogHeadline extends HTMLElement {
constructor() {
super();
}
get type() {
return this.getAttribute("type");
}
set type(newValue) {
this.setAttribute("type", newValue);
this.setIcon(this.type);
}
setIcon(type) {
if (type === "cats") {
this.textContent = "? " + this.textContent + " ?";
}
else if (type === "dogs") {
this.textContent = "? " + this.textContent + " ?";
}
}
}
constructor() {
super();
if (this.hasAttribute("type")) {
this.setIcon(this.type);
}
}
<main class="blog-posts">
<article>
<blog-headline type="cats" newpost>Blog Post About Kittens</blog- headline>
</article>
<article>
<blog-headline type="dogs">Blog Post About Puppies</blog-headline>
</article>
</main>
constructor() {
super();
if (this.hasAttribute("type")) {
this.setIcon(this.type);
}
this.newpost = this.hasAttribute("newpost");
}
get newpost() {
return this.hasAttribute("newpost");
}
set newpost(newValue) {
if (newValue) {
this.setAttribute("newpost", "");
}
else {
this.removeAttribute("newpost");
}
this.setIsNewPostIcon(this.newpost);
}
setIsNewPostIcon(isNewPost) {
if (isNewPost) {
this.classList.add("new-post");
}
else {
this.classList.remove("new-post");
}
}
The setIsNewPostIcon method will add a class called "newpost" to the blog-headline element if the newpost attribute exists on the element. We have a boolean (true or false) value called isNewPost, which is the argument for the setIsNewPostIcon method. This isNewPost value is set to true or false based on whether the this.hasAttribute('type') check is true or false. The class will add a clock icon to elements with the newpost attribute via CSS.
<style>
.new-post::before {
font-weight: bold;
content: "⏰ NEW! ⏰";
}
</style>
If you now right-click on the filename in VSCode on the left-hand side of the screen and select open in default browser, you will see the following image that shows what this would look like in the browser:
There are a couple of problems with the blog-headline element that we have created.
One problem that may cause us issues is that the element is not encapsulated; it could influence and be influenced by the rest of the web page. For example, we have added the new-post class to the head element, which means it can be used throughout the HTML document. We want our web components to be more contained.
We will look at the Shadow DOM later in this chapter and learn how we can use that technology to protect a component from outside influences.
Equally problematic is the fact that we are only checking attributes in the constructor of our BlogHeadline class. This means that if we change attributes with JavaScript, they will not be reflected in the blog-headline instances on the page.
To handle the second problem and many other issues, custom elements give us some life cycle methods, which we will look at in the next section.
A custom element has several life cycle methods that can be used to call blocks of code at the right time in the custom elements life cycle.
The constructor of a custom element is called when the element is instantiated, but there are several ways to create new instances of an element for a web page: adding them in an HTML document or creating them with JavaScript using document.createElement. This means we can't guarantee that a custom element has been connected to the web page when the constructor is called. This is why custom elements provide a connectedCallback method.
The connectedCallback method is invoked when a custom element is added into the HTML document. It can happen more than once, for example, if an element is connected and then disconnected and then reconnected to the document.
The disconnectedCallback method is invoked when the element is disconnected from the HTML document.
The adoptedCallback method is invoked if the custom element moves to a new document. An example of this callback being invoked is when we move a custom element between an HTML document and an iframe.
The attributeChangedCallback method is invoked when an element's attributes change, that is, whether the value changes or the attribute is added or removed. The callback receives three arguments:
Not all changes to attributes will trigger attributeChangedCallback; rather, we, as developers, are responsible for maintaining a whitelist of attributes we wish to observe. We do this with the static observedAttributes method.
For example, if I wanted to know when the type attribute changed in our previous exercise, I would set the array returned by the observedAttributes method to include "type". I would then handle the change to the type attribute with attributeChangedCallback like so:
static get observedAttributes() {
return ["type"];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === "type") {
// handle changes to the value of the type attribute
}
}
Something else to consider is that oldValue and newValue may not necessarily be different. The callback is invoked because the attribute has been set, but depending on what we want to do when the value changes, we may want to check whether the value has actually changed. We can do this by comparing the old and new value and escaping the function as early as possible if the two values are the same:
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) { return; }
In the next exercise, we will make changes to the blog-headline element. This time, we will use the life cycle methods to improve our custom element. We will add some functionality to trigger the different life cycle methods so that we can experience them for ourselves in a better way.
We will start from where we left off in the previous exercise. In this exercise, we will make further improvements to the blog-headline element and we will add some functionality so that we can test the life cycle of our custom element by adding and removing the element and changing attributes dynamically.
Here are the steps to follow:
.test-ui {
margin-top: 2rem;
}
static get observedAttributes() {
return ["type", "newpost"];
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) { return; }
if (name === "type") {
this.setIcon(this.type);
}
else if (name === "newpost") {
this.setIsNewPostIcon(this.newpost);
}
}
get heading() {
return this._heading;
}
constructor() {
super();
this._heading = this.textContent;
}
<div class="test-ui">
<button id="swap-type-1">Swap type attribute</button>
<button id="swap-newpost-1">Swap newpost attribute</button>
</div>
const headline = document.querySelector("blog-headline");
const swapTypeButton = document.getElementById("swap-type-1");
const swapNewpostButton = document.getElementById("swap-newpost-1");
swapTypeButton.addEventListener("click", function() {
const type = headline.getAttribute("type");
headline.setAttribute("type", type == "cats" ? "dogs" : "cats");
});
swapNewpostButton.addEventListener("click", function() {
const newpost = headline.hasAttribute("newpost");
if (newpost) {
headline.removeAttribute("newpost");
}
else {
headline.setAttribute("newpost", "");
}
});
With those buttons, we can test whether the attribute value changes are reflected in our blog-headline element. For example, if we click the Swap type attribute button when the blog-headline has cats, it will swap to dogs and the icon should change to dogs.
So far, the code we have written will result in the following output:
By clicking the left button, we can change the type attribute of the top blog-headline element, as shown in the following image:
By clicking the right button, we can toggle the newpost attribute of the top blog-headline element and remove the new-post class attribute, as shown in the following image:
<div class="test-ui">
<button id="swap-type-1">Swap type attribute</button>
<button id="swap-newpost-1">Swap newpost attribute</button>
<button id="toggle-connect-1">Toggle connection</button>
</div>
const headline = document.querySelector("blog-headline");
const swapTypeButton = document.getElementById("swap-type-1");
const swapNewpostButton = document.getElementById("swap-newpost-1");
const toggleConnectionButton = document.getElementById("toggle-connect-1");
const headlineParent = headline.parentElement;
toggleConnectionButton.addEventListener("click", function() {
if (headline.parentElement) {
headlineParent.removeChild(headline);
}
else {
headlineParent.insertBefore(headline, headlineParent.firstChild);
}
});
connectedCallback() {
console.log("Connected");
this.setIcon(this.type);
}
disconnectedCallback() {
console.log("Disconnected");
}
If you now right-click on the filename in VSCode on the left-hand side of the screen and select open in default browser, you will see the results of the exercise that is shown in the following image. The buttons let us update the first headline by changing the element's attributes and connecting and disconnecting the element:
If we open the Chrome developer tools, we will see the messages Connected and Disconnected logged in the console each time we click the Toggle connection button, as shown in the following screenshot:
So far, we have looked at the life cycle of a custom element. Now, we will look at how we can extend built-in elements such as the anchor (<a />) element.
Our custom element does not have to directly extend HTMLElement as long as it extends another built-in element that is a subclass of HTMLElement. This means we can customize the behavior of existing elements such as the p element via HTMLParagraphElement or the a element via HTMLAnchorElement.
We extend the custom element with the third optional argument of the customElements.define method. We pass the argument an options object with an extends property. The value we give the extends property is the name of the built-in element we wish to extend. For example, if we want to create a custom h1 element that restricts the size of the text content, we would set the extends property to "h1":
window.customElements.define("short-headline", ShortHeadline, { extends: "h1"});
The ShortHeadline constructor would now extend HTMLHeadingElement instead of HTMLElement:
class ShortHeadline extends HTMLHeadingElement {
//… functionality for short headlines
}
To use this custom element, we would not create a short-headline element; instead, we would use the is attribute on an h1 element and set the value to our short-headline custom element:
<h1 is="short-headline">Headline</h1>
If we extend an element, we can use all the properties and attributes available to that element. For example, we could extend the anchor tag based on its attributes. We could add information about a link, such as whether it opens in a new browser window.
In the next exercise, we will look at how we can extend the anchor element to provide this information.
In this exercise, we are going to create a new custom element that extends the anchor element and provides information about a link before a user clicks it.
We are going to take advantage of the target attribute of the anchor element. The target attribute can be set to _self, _blank, _parent, or _top. The default behavior, _self, is for a URL that's navigated to from the anchor element to load in the current browser context. When the target is set to _blank, the URL will launch in a new tab or window (depending on your browser's configuration). The other two options relate to iframes, that is, launching in the parent or top-level browser context. They will both act the same as _self if there is no parent context.
Here are the steps:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Exercise 12.04: Extending HTMLAnchorElement</title>
</head>
<body>
<main class="blog-post">
<ul class="links">
<li><a href="https://www.packtpub.com/web-development">Link that opens in current window</a></li>
<li><a href="https://www.packtpub.com/free-learning" target="_blank">Link that opens in a new window.</a></li>
</ul>
</main>
<script>
</script>
</body>
</html>
<script>
class AnchorInfo extends HTMLAnchorElement {
constructor() {
super();
this._originalText = this.textContent;
}
}
window.customElements.define("anchor-info", AnchorInfo, {extends: "a"});
</script>
static get observedAttributes() {
return ["target"];
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) { return; }
switch (name) {
case "target":
this.checkTarget();
break;
}
}
checkTarget() {
this.opensInNewTab = this.target === "_blank";
}
checkTarget() {
this.opensInNewTab = this.target === "_blank";
this.render();
}
render() {
const targetState = this.opensInNewTab ? " ⧉" : "";
this.textContent = this._originalText + targetState
}
<ul class="links">
<li><a is="anchor-info" href="https://www.packtpub.com/web-development">Link that opens in current window</a></li>
<li><a is="anchor-info" href="https://www.packtpub.com/free-learning" target="_blank">Link that opens in a new window.</a></li>
</ul>
The second link on the page opens in a new window. If you now right-click on the filename in VSCode on the left-hand side of the screen and select open in default browser, you will see it will have the external link icon added, as shown in the following screenshot:
We've looked at creating custom elements but up until now, we haven't been able to encapsulate the element. We will look at the Shadow DOM and see how it can help protect our custom element from its context and vice versa.
The Shadow DOM is a feature that lets us control access to parts of the DOM.
If we think of our HTML document as the non-shadow, or light, DOM then all the objects and styles are accessible from the root of the document by traversing the DOM tree.
Even if we were to include a third-party script or stylesheet on our web page, we could still change the style or behavior with our own code. A user can even add their own stylesheets in many browsers and run scripts on your page through dev tools or via extensions.
The Shadow DOM lets us protect parts of our code from these outside influences, which is vital if we want our web components to work across various web pages or apps where we can't possibly know what the context is.
The Shadow DOM is an HTML fragment or DOM tree that is hidden from the rest of the light DOM. It needs to be attached to a shadow host, which is a node in the visible DOM. As with all DOM trees, the Shadow DOM will have a Shadow root from which the rest of the tree branches.
To make use of the Shadow DOM, we need to use the attachShadow method on an element via JavaScript. When we call the attachShadow method, we can pass an options object with a mode property. There are two options available for the mode property: "open" and "closed".
Here's an example of attaching a shadow DOM in open mode to a div element with the "host" ID attribute. When we attach the shadow DOM in open mode, we are able to access the shadow root of the shadow DOM, which means we can access that DOM from the outside:
<div id="host"></div>
<script>
const hostElement = document.getElementById("host");
const openShadowDOM = hostElement.attachShadow({ mode: "open" });
const shadowRoot = hostElement.shadowRoot;
console.log(shadowRoot); // will return the shadow root
</script>
If we have a reference to the shadow root, we can manipulate the shadow DOM, which means we can append elements to it or remove or change elements within the DOM.
For example, we can append a paragraph to the shadow DOM via the root:
<div id="host"></div>
<script>
const hostElement = document.getElementById("host");
hostElement.attachShadow({ mode: "open" });
const shadowRoot = hostElement.shadowRoot;
const paragraph = document.createElement("p");
paragraph.textContent = "Lorem ipsum, etcetera"
shadowRoot.appendChild(paragraph);
</script>
The open mode does offer a degree of encapsulation. For example, if we try to apply a style to the head element of a web page that applies to all the paragraph elements, it will not apply to a paragraph within the Shadow DOM. We have a single point of access via the shadow root.
We can see this via the result of the following code, which is shown in the screenshot following this code:
Example 12.02.html
16 <script>
17 const hostElement = document.getElementById("host");
18 hostElement.attachShadow({ mode: "open" });
19 const shadowRoot = hostElement.shadowRoot;
20
21 const paragraph = document.createElement("p");
22 paragraph.textContent = "Paragraph in the Shadow DOM";
23 shadowRoot.appendChild(paragraph);
24 </script>
The full example code is available at: https://packt.live/2Nr61po
In the following screenshot, we can see that the two paragraphs either side of our host div have been styled according to a style rule targeted at the p element selector. However, despite the shadow DOM having a paragraph element, it has not received the style.
This is an example of the encapsulation the Shadow DOM achieves, even in open mode.
We have included the dev tools representation of the elements in the following screenshot as this shows the shadow root (#shadow-root and the mode in brackets). We will explore the topic of using the dev tools to inspect the Shadow DOM further in the next section:
By changing the mode to closed, we change the output of the shadow root. It will now return null, which means we cannot access the Shadow DOM from the rest of the DOM through the shadowRoot property of an element:
<div id="host"></div>
<script>
const hostElement = document.getElementById("host");
hostElement.attachShadow({ mode: "closed" });
const shadowRoot = hostElement.shadowRoot;
console.log(shadowRoot); // will return null
</script>
This does not mean, however, that you cannot get a reference to the shadow root. attachShadow will still return a reference to shadowRoot. The idea is that you will use this inside a custom element or a web component and therefore will have a reference to the shadow root without it being accessible via an element in the main HTML document.
Most browsers come with a set of tools designed to help web developers when they are working on a web page. In the next exercise, we will use the Chrome dev tools to inspect the Shadow DOM of the browser's in-built input element.
It can be useful to inspect the Shadow DOM of in-built elements as it will give us a better idea of what these elements are doing and how to build our own web components.
To inspect the Shadow DOM of browser elements in the Chrome dev tools, we need to know a little bit about the dev tools and we need to set them up so that they let us inspect the Shadow DOM.
In Chrome, we can access the dev tools with the keyboard shortcuts Ctrl + Shift + I (on PC) and Cmd + Opt + I (on Mac). On most websites, you can also access the context menu by right-clicking and then choosing Inspect from the menu options.
The dev tools will appear like so:
The Chrome dev tools provide a vast array of tools for web development, most of which are beyond the scope of this chapter. Across the top panel in the preceding screenshot, we can see a set of tabs, including Elements, Console, Network, and so on. We will be focusing on the Elements tab here.
The Elements tab gives you access to the HTML document of your web page. You can hover over elements in the Elements display and they will be highlighted on the web page. You can also edit these elements; change the text or attributes to immediately see what effect those changes will have on your web page.
It is also possible to inspect the Shadow DOM via the Elements tab. To do this, we may need to change the settings of our dev tools. On the right of the top panel, next to the cross that closes the dev tools, there are three vertical dots that open the controls for the dev tools. Click this and select the Settings option. Alternatively, you can press the F1 key. This will bring up the Settings pane that's shown in the following screenshot:
There are a great many settings available for you to change, but we want to find the Elements heading and toggle Show user agent shadow DOM so that it is checked. We can now close the settings pane and return to the Elements tab.
We should now have access to the shadow DOM of the browser. You can determine a shadow DOM because the shadow root is marked in the Elements panel. For example, given an input element with the type set to number, we will see the results shown in the following screenshot in the Elements panel:
<input type="number" name="tel">
In the preceding screenshot, we can see that the input element actually attaches a Shadow DOM. Attached to the shadow root, we can see a container div (with an ID of 'text-field-container') hosting a viewport and a spin button that lets you increment the number value of the input.
In the next exercise, we will go back to a custom element we worked on in Exercise 12.01, Creating a Custom Element, Exercise 12.02, Custom Element with Attributes, and Exercise 12.03, Custom Element Life Cycle, and look at how we can use the Shadow DOM with that custom element to encapsulate its structure and styles.
In this exercise, we will encapsulate the structure and styles of a custom element with the Shadow DOM.
Here are the steps:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Exercise 12.05: Shadow DOM</title>
<style>
.new-post::before {
font-weight: bold;
content: "⏰ NEW! ⏰";
}
</style>
</head>
<body>
<main class="blog-posts">
<article>
<blog-headline type="cats" newpost>Blog Post About Kittens</blog- headline>
</article>
<article>
<blog-headline type="dogs">Blog Post About Puppies</blog-headline>
</article>
</main>
<script>
class BlogHeadline extends HTMLElement {
static get observedAttributes() {
return ["type", "newpost"];
}
constructor() {
super();
this._heading = this.textContent;
}
connectedCallback() {
this.setIcon(this.type);
}
disconnectedCallback() {
console.log("Disconnected");
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) { return; }
switch(name) {
case "type":
this.setIcon(this.type);
break;
case "newpost":
this.setIsNewPostIcon(this.newpost);
break;
}
}
get heading() {
return this._heading;
}
get type() {
return this.getAttribute("type");
}
set type(newValue) {
this.setAttribute("type", newValue);
this.setIcon(this.type);
}
get newpost() {
return this.hasAttribute("newpost");
}
set newpost(newValue) {
if (newValue) {
this.setAttribute("newpost", "");
}
else {
this.removeAttribute("newpost");
}
this.setIsNewPostIcon(this.newpost);
}
setIcon(type) {
switch(type) {
case "cats":
this.textContent = "? " + this.heading + " ?";
break;
case "dogs":
this.textContent = "? " + this.heading + " ?";
break;
}
}
setIsNewPostIcon(isNewPost) {
if (isNewPost) {
this.classList.add("new-post");
}
else {
this.classList.remove("new-post");
}
}
}
window.customElements.define("blog-headline", BlogHeadline);
</script>
</body>
</html>
constructor() {
super();
this._heading = this.textContent;
this.textContent = "";
this.headingElement = document.createElement("h1");
this.headingElement.textContent = this._heading;
this.appendChild(this.headingElement);
}
setIcon(type) {
if (type === "cats") {
this.headingElement.textContent = "? " + this.heading + " ?";
}
else if (type === "dogs") {
this.headingElement.textContent = "? " + this.heading + " ?";
}
}
setIsNewPostIcon(isNewPost) {
if (isNewPost) {
this.headingElement.classList.add("new-post");
}
else {
this.headingElement.classList.remove("new-post");
}
}
h1 {
font-family: Arial, Helvetica, sans-serif;
color: white;
background:lightskyblue;
padding: 16px;
}
</style>
</head>
<body>
<main class="blog-posts">
<article>
<h1>Normal H1 Heading</h1>
</article>
If we look at the results of the code so far, we will see that the h1 style affects both our normal h1 element and the blog-headline custom elements, as shown in the following output:
constructor() {
super();
this._heading = this.textContent;
this.textContent = "";
this.attachShadow({ mode: "open" });
this.headingElement = document.createElement("h1");
this.headingElement.textContent = this._heading;
this.shadowRoot.appendChild(this.headingElement);
}
The result will be the encapsulation of our blog-headline's DOM from the surrounding document, which we can see in the following screenshot:
this.styleElement = document.createElement("style");
this.styleElement.innerText = `
.new-post::after {
font-weight: bold;
content: "⏰ NEW! ⏰";
margin-left: 8px;
}
`;
this.shadowRoot.appendChild(this.styleElement);
We've seen how we can encapsulate the structure and style of our custom elements using the Shadow DOM, but using DOM manipulation in JavaScript to work with the Shadow DOM is not as easy as working with HTML.
In the next section, we will see how we can improve the developer experience of working with web components using HTML templates. We will look at how we can use templates to create versatile HTML structures that we can use in an encapsulated component.
HTML templates let you create flexible templates in HTML that you can use in multiple places. If you have an HTML structure that is used several times on a web page but with different content or data, you can create a template element that defines that structure without immediately showing it to the user on the web page. We can reference the template to create multiple copies of that HTML structure.
For example, we could create a very simple template for a styled button with the following code, which creates a template with an ID attribute of ok-button-template. The markup is simply a button element with the btn and ok-btn classes applied to it. The button is styled with a purple background color, white text, and a hover state that darkens the background:
<template id="ok-button-template">
<style>
.btn {-webkit-appearance: none;
appearance: none;
background-color: #3700B3;
border: none;
border-radius: 2px;
color: white;
cursor: pointer;
min-width: 64px;
outline: none;
padding: 4px 8px;}
.btn:hover {background-color: #6200EE;}
</style>
<button class="btn ok-btn">OK</button>
</template>
If we created this template in a web page, we wouldn't see it in the page. To use it, we have to attach it to the HTML document. We can easily do that by getting a reference to the template and appending it to the body of the HTML document, like so:
<script>
const buttonTemplate = document.getElementById("ok-button-template");
document.body.appendChild(buttonTemplate.content);
</script>
This code will get the content of our template and append it to the HTML document. The result is a button on the web page, as shown in the following screenshot:
We may come across a problem with adding templates using this method. If we tried to do the same again to create two buttons, we would only see one button. Templates are meant to be reusable, so what is going on here? The answer is: we need to clone the content of the template each time we want to use it. For example, we can create multiple buttons with cloneNode:
<script>
const buttonTemplate = document.getElementById("ok-button-template");
document.body.appendChild(buttonTemplate.content.cloneNode(true));
document.body.appendChild(buttonTemplate.content.cloneNode(true));
</script>
HTML templates are particularly useful when paired with the Shadow DOM in web components because we can create a structure and attach it to the shadow DOM and minimize JavaScript DOM manipulation.
A minimal example of using ok-button-template and combining it with a custom element could look like this:
<script>
class StyledButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
const buttonTemplate = document.getElementById("ok-button- template");
const node = document.importNode(buttonTemplate.content, true);
this.shadowRoot.appendChild(node);
}
}
window.customElements.define("styled-button", StyledButton);
</script>
We've attached a shadow DOM again and this time, when the custom element is connected to the DOM, we use importNode to import the node from one document into a subdocument (in this case, the Shadow DOM of our component). Then, we can create instances of our OK button in the HTML document with the <styled-button /> custom element.
As useful as it is to create multiple instances of a button with the OK label, it is perhaps even more useful to be able to use a template but vary the content when we create new instances. We can do that using the slot element in our template. This lets us set areas of the template that we want to be able to change.
For example, we can change our OK button to have any label by creating a label slot:
<template id="slotted-button-template">
<button class="btn primary-btn"><slot>Label</slot></button>
</template>
Here, we have set a default placeholder as a label but if we instantiate the button with text content, the slot will be replaced with that text content.
Here is the complete code example of creating a default version of the button and two labeled versions:
Example 12.03.html
<template id="styled-button-template">
<style>
.btn {
-webkit-appearance: none;
appearance: none;
background-color: #3700B3;
border: none;
border-radius: 2px;
color: white;
cursor: pointer;
min-width: 64px;
outline: none;
padding: 4px 8px;
}
.btn:hover {
background-color: #6200EE;
}
</style>
<button class="btn primary-btn"><slot>Label</slot></button>
</template>
The full example code is available at: https://packt.live/2WQjwC8
The output is as follows:
You can also create named slots in a template by adding the name attribute to a slot element, which can then be used to target multiple slots with different content. For example, a template could have named slots for a heading and content:
<template id="article-template">
<article>
<h1><slot name="heading">Heading goes here…</slot></h1>
<slot name="content">Content goes here…</slot>
</article>
</template>
If we used this template in a custom element called short-article, we would then populate it:
<short-article>
<span slot="heading">HTML and CSS</span>
<p slot="content">HTML and CSS are the foundations of a web page.</p>
</short-article>
We now have a reusable custom element; we can provide different content to each instance and because they are HTML elements, we can provide children that are also custom elements.
To see the power of HTML templates in action, we are going to try them out on a slightly more complex structure with slots in the next exercise.
In this exercise, we will use HTML templates to create a modal, which we will turn into a component called simple-modal.
Here are the steps:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Exercise 12.06: HTML Template</title>
<style>
html, body { margin: 0; padding: 0; }
</style>
</head>
<body>
<simple-modal></simple-modal>
</body>
</html>
<template id="simple-modal-template">
<section class="modal-container">
<div class="overlay"></div>
<div class="modal">
<header class="header">
<h1><!-- heading here --></h1>
</header>
<div class="body">
<!-- content here -->
</div>
<footer class="footer">
<button class="btn">OK</button>
</footer>
</div>
</section>
</template>
<style>
.modal-container {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
}
.overlay {
position: fixed;
background: rgba(0, 0, 0, 0.5);
top: 0;
bottom: 0;
left: 0;
right: 0;
}
.modal {
min-width: 250px;
position: relative;
background: white;
padding: 16px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.8);
}
.btn {
-webkit-appearance: none;
appearance: none;
background-color: #3700B3;
border: none;
border-radius: 2px;
color: white;
cursor: pointer;
min-width: 64px;
outline: none;
padding: 4px 8px;
}
.btn:hover {
background-color: #6200EE;
}
</style>
<script>
customElements.define("simple-modal", class SimpleModal extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
const tmpl = document.getElementById("simple-modal-template");
const node = document.importNode(tmpl.content, true);
this.shadowRoot.appendChild(node);
}
});
</script>
So far, we have created our modal and an instance of it appears on our web page, like so:
<template id="simple-modal-template">
<section class="modal-container">
<div class="overlay"></div>
<div class="modal">
<header class="header">
<h1><slot name="heading"></slot></h1>
</header>
<div class="body">
<slot name="content"></slot>
</div>
<footer class="footer">
<button class="btn">OK</button>
</footer>
</div>
</section>
</template>
<simple-modal>
<span slot="heading">You've Opened a Modal!</span>
<p slot="content">
Finished with the modal? Click OK.
</p>
</simple-modal>
If you now right-click on the filename in VSCode on the left-hand side of the screen and select open in default browser, you will see the result of our component as shown in the following screenshot:
We have now looked at the three technologies that are vital for making web components available in the browser. It is time to put all three together and create a web component.
By combining custom elements with a Shadow DOM and HTML templates, we have a convenient set of tools for making a reusable component – a web component – that is encapsulated from the document it appears in.
We are going to create our own reusable components in the upcoming activity. This component will add an avatar to a web page with a default placeholder image that can be replaced by our own avatar image.
We've been tasked with creating a profile component for the Films On Demand website.
The profile will be a web component. It will include the name and email of the user and an avatar, which will show a default placeholder image (a silhouette). We will be able to replace the image with a profile image of the user.
The avatar placeholder can be found at https://packt.live/2K0jcLT. We want create a profile component similar to the following screenshot:
<script>
customElements.define("fod-profile", class FODProfile extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
const tmpl = document.getElementById("fod-profile- template");
const node = document.importNode(tmpl.content, true);
this.shadowRoot.appendChild(node);
}
});
</script>
Note
The solution to this activity can be found on page 627.
In this activity, we've learned how to create a reusable web component, but there are still some improvements we can make to the shareable nature of our component.
The great benefit of web components is that they are reusable and because of the protections that the Shadow DOM and templates afford, we can be fairly certain that they can be used on different websites with ease. This means we can share components and use third-party components with more confidence.
To make our component shareable, we really want to make it something that is self-contained in a single file that can be attached to a web page and then used as a component.
To do this, we can put any scripts in an external file; for example, working from the simple-modal component we created in Exercise 12.06, Template, we can create an external script file called simple-modal.js that wraps the custom element definition in an immediately executing function:
(function() {
customElements.define("simple-modal", class SimpleModal extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
const tmpl = document.getElementById("simple-modal-template");
const node = document.importNode(tmpl.content, true);
this.shadowRoot.appendChild(node);
}
});
})();
We still need to include the HTML template for the component in this file. The easiest way to do this is to create the template dynamically and use a template literal to populate it. We then add the template to the document body so that we can use it:
const template = document.createElement("template");
template.id = "simple-modal-template";
template.innerHTML = `
<style>
.modal-container {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
}
.overlay {
position: fixed;
background: rgba(0, 0, 0, 0.5);
top: 0;
bottom: 0;
left: 0;
right: 0;
}
.modal {
min-width: 250px;
position: relative;
background: white;
padding: 16px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.8);
}
.btn {
-webkit-appearance: none;
appearance: none;
background-color: #3700B3;
border: none;
border-radius: 2px;
color: white;
cursor: pointer;
min-width: 64px;
outline: none;
padding: 4px 8px;
}
.btn:hover {
background-color: #6200EE;
}
</style>
<section class="modal-container">
<div class="overlay"></div>
<div class="modal">
<header class="header">
<h1><slot name="heading"></slot></h1>
</header>
<div class="body">
<slot name="content"></slot>
</div>
<footer class="footer">
<button class="btn">OK</button>
</footer>
</div>
</section>
`;
document.body.appendChild(template);
The template and custom element definition can now be contained in one file, which is easy to add to a web page. For example, to use simple-modal.js, we would add the file and create an instance of the simple-modal element:
<body>
<simple-modal>
<span slot="heading">You've Opened a Modal!</span>
<p slot="content">
Finished with the modal? Click OK.
</p>
</simple-modal>
</body>
<script src="simple-modal.js"></script>
In this chapter, through multiple exercises and activities, we have looked at the features that have been added to HTML5 that allow us to create web components. We have learned how to create a custom HTML element and how to define the behavior of that custom element. We have also learned about the Shadow DOM and how we can use it to encapsulate our custom elements; in other words, we have learned how to keep our custom elements safe from outside influences and prevented them, in turn, from polluting the rest of a web page. Finally, we have learned how to create HTML templates that make our custom elements more flexible and allows us to reuse components in more situations.
Combining all of these features of HTML5, we have applied our new knowledge to create a modal and a blog-headline element, and we have learned how to create web components that can interact with one another to make reusable, versatile UI components that can be used across multiple projects.
In the next chapter, we will be looking at new web technologies to see exciting and experimental features you may want to work with.