The real problem with Web Components is simple: There is no clearly defined road to success for using them. No singular go-to guide. Up until recently the standard has been in flux. And in some ways, still is. But the core APIs have stabilized, and many developers are jumping into the scene.
However, it can be hard for someone who hasn't followed the evolution of this standard to know where to begin. Learning the concepts necessary to use this powerful set of tools is a challenge. Many articles exist on this subject, but the real useful information is scattered, buried among outdated tutorials demonstrating experimental features that have changed over the years.
So, if you have been wondering how to get to started crafting your own components from scratch, you are reading the right article.
The Anatomy of a Web Component
Let's begin with the fundamentals. The key aspect of Web Components that make them so useful is the ability to create Custom HTML Elements. You have probably also heard of HTML Templates and Shadow DOM. These additional APIs give us power over a browser's native encapsulation methods, but they are not essential to understand just yet. Before we dive into those advanced topics, let's explore how browsers handle Custom Elements.
Understanding Custom Elements
When an HTML document is parsed, element instances are created based on the tags found. Standard elements like <div>
, <p>
, or <button>
are well-known to browsers. But what happens when you try to create your own tag?
<component></component>
Browsers can't go throwing a fit over every tag they don't recognize. And they can't just drop data that was meant to be delivered to the user. So they initialize these undescribed tags as instances of HTMLUnknownElement
and render their contents as plain text or even continue to parse the subtree of nested HTML. This behavior is what underlies the ability to define our own elements.
Defining a Custom Element
HTML has a well defined set of tags. This set doesn't change very often. We got quite a few new semantic elements when HTML5 was released and not many since. In the past, we could only hope for new elements. Then we made complex abstractions out of <div>
and other elements. Now, we can streamline those abstractions into re-usable components that add meaning to our document. But the HTML root namespace needs to be left available for future built-in tags. As a result, if you want to create your own tag, it must contain at least one hyphen -
.
<my-element>Hello World</my-element>
<my-next-element>
<span> This </span>
<span> is </span>
<span> a </span>
<span> test </span>
</my-next-element>
By doing this, we have told the browser that this is NOT an unknown element anymore. We are taking responsibility for it. This element will instead be parsed as a generic inline HTMLElement
, not unlike a <span>
.
You can target them with CSS, just like any other element.
my-element {
display: block;
padding: 1em;
color: black;
background: green;
}
my-next-element {
display: flex;
flex-flow: row wrap;
justify-content: space-around;
}
You can query them with JS, just like any other element.
const myElement = document.querySelector('my-element');
console.log(myElement instanceof HTMLElement); // true
const myNextElement = document.querySelector('my-next-element');
console.log(myNextElement.children); // HTMLCollection(4)
All of the base HTMLElement
interface features are available, like events.
myElement.addEventListener('click', event => {
console.log(event.target);
})
But we've only just scratched the surface of the world of Web Components.
Registering a Custom Element
To transform our generic element into something meaningful, we need to register it with the browser.
class MyElement extends HTMLElement {
constructor() {
super();
}
}
customElements.define('my-element', MyElement);
By itself, the example above does not provide any benefit beyond what we have already done through markup alone. But it sets up the foundation of our new modular, reusable component. Before Custom Elements existed, we would have to: Wait for the DOM to be fully parsed, query it to select all instances of some specific element, and wrap our functionality around those elements using external scripting.
With Custom Elements, we can now embed that logic directly into the element's definition. We no longer have to go searching for the elements in the DOM. Every time our element is encountered, it will automatically use our class that we have defined for it.
Harnessing The Power
Registering our element gives us the flexibility to do all sorts wizardry. We can parse the element for children, we can detect new children, we can add our own children. We can manipulate the element however we want. It really depends on your use-case. Every component is different. The possibilities are endless.
But we have to start somewhere. So let's build a simple example. We will create a <copy-text>
element. When this element is clicked, it's text content will be copied to the clipboard.
We could accomplish this with some JavaScript targeting a <span>
, but having a dedicated element makes the intention clear in our markup. It also lets us create unique rules in our stylesheet that do not require excessive class or ID scoping. And we don't have to change any code to add more instances of our element.
Notice how I have used #private
methods inside the class definition. This allows us to protect the internal functionality of our component so that it cannot be manipulated from the outside. This will become more important later when we get into encapsulation. For now, we've created a component that lives entirely in the root document. The page's stylesheet fully covers the element. Its entire structure can be accessed from the document context.
Reusing Custom Elements
A Custom Element only needs to be initialized once per browsing context. Including our component definition either as an inline or fetched script is enough to let us use it as many times as we want, anywhere we want.
<!DOCTYPE html>
<title> Copy Text Element Example </title>
<link rel="stylesheet" href="theme.css">
<script type="module" src="copy-text.js"></script>
<div>
<label> Example: </label>
<copy-text title="Copy Your API Key">
A_REALLY_LONG_API_KEY_OR_WHATEVER
</copy-text>
</div>
<div>
<label> Example 2: </label>
<copy-text>
Some more text to copy
</copy-text>
</div>
If we export the class from our module, we can also import it into other scripts and create custom instances.
// at end of copy-text.js
export { CopyTextElement }
// in another component
import { CopyTextElement } from './copy-text.js';
const copyText = new CopyTextElement();
However, this is not necessary if you are 100% confident that your module is already loaded. In that case we can simply create an instance like any other element. If needed, we can also get our class from the element registry.
const copyText = document.createElement('copy-text');
const CopyTextElement = customElements.get('copy-text');
const anotherCopy = new CopyTextElement();
If you can't control the load order of your components, you can also wait for your element to be registered.
customElements.whenDefined('copy-text')
.then(CopyTextElement => {
const copyText = new CopyTextElement();
});
We Have to go Deeper
So far we've only really discussed how a Custom Element behaves on it's own. Now we need to handle an element that contains a subtree. In a legacy web app, we would select our collection of elements by targeting the host element. With a Custom Element, we can make the element itself be responsible for it's own children. The best way to handle this is with a Mutation Observer.
We set up the initial event handlers, then dynamically add or remove them from any elements that enter or leave the subtree. Notice we initialize everything in the constructor. While many tutorials focus on connectedCallback
as the most important part of the lifecycle, this isn't necessarily the case. The constructor is the perfect place to set up our component's core functionality.
The main reason for this is so that our element can handle children immediately. We don't wait until it is connected to the DOM. This allows us to create instances of this element through code that are already configured before they are added to the document. In this example we only added click handlers. But we can use this for much more complex logic if necessary. Rather than delaying rendering when inserted, our element is ready to go right away.
const clickHander = document.createElement('click-handler');
[ 'test', 'another', 'more' ].forEach(label => {
const span = document.createElement('span');
span.textContent = label;
clickHandler.append(span);
});
requestAnimationFrame(() => {
document.append(clickHandler);
});
Elegant and Flexible
We've seen how Custom Elements can enable reusable functionality in a simple, modular way. No build tools, or framework dependencies. Just meaningful markup, classic CSS, and vanilla JS. Web Components that work anywhere. But all we've done so far is streamline the methods we already had available. There is much more that this toolkit has to offer.
In the next article, we'll explore how HTML Templates can let us easily upgrade simple, declarative, definitions into complex markup structures. We'll also cover how to make the most of Shadow DOM to fully or selectively encapsulate our components.
Until then, try building your own Custom Elements. Start simple. Focus on solving real problems. Here's a few ideas to get the creative juices flowing:
-
<format-number>
: Display numbers with specific formatting (currency, decimals, etc.) -
<tool-tip>
: Show some popup descriptive text when hovered or tapped. -
<marquee-text>
: A throwback to one of the earliest non-standard elements. -
<nav-bar>
: A website menu that automatically highlights the active route.
Love how you break this down, it's always felt a bit mysterious to me.
What's been your favorite real-world use case for custom elements so far?