Introduction
In this article we aim to develop a fundamental understanding of what Web Components are, by methodically and comprehensively building and modifying our own standalone Web Component through a variety of patterns. Let’s jump right in.
The Component – A Terrapin Card
To start off, let’s build our first Web Component and render it with an html file. I like pets, and I used to keep terrapins, so let’s use that as an example.
<!DOCTYPE html>
<script>
class TerrapinCard extends HTMLElement {
connectedCallback() {
const div = document.createElement('div')
const pIsSwimming = document.createElement('p')
const pHeight = document.createElement('p')
const pWeight = document.createElement('p')
pIsSwimming.textContent = 'Is Swimming: Yes'
pHeight.textContent = 'Height: 0.1m'
pWeight.textContent = 'Weight: 0.2kg'
div.appendChild(pIsSwimming)
div.appendChild(pHeight)
div.appendChild(pWeight)
this.appendChild(div)
}
}
customElements.define('terrapin-card', TerrapinCard)
</script>
<div>
<terrapin-card></terrapin-card>
</div>
</html>
Put the above straight into a file main.html
, open it in a browser, and it should just work.
In this simple example alone, we have the 3 essential components of a Web Component:
class TerrapinCard extends HTMLElement { ... }
, a pattern to extend HTMLElement that builds the Web Component itself. HTMLElement is exactly what its name describes: an abstraction of the HTML elements rendered in your browser, and the parent class that all rendered HTML elements inherit from. For an example to show how prevalent this class is, the body tag itself inherits from HTMLElement.customElements.define
, which is used to register the new Web Component. Notice how this is still part of the script tag, as regular JavaScript.<terrapin-card></terrapin-card>
, where we use our new Web Component in the HTML markup. Note the use of kebab-case as a naming convention, this is standardised for web components. In additional, we use the full closing tag syntax for maximum browser compatibility.
A closer look at our Web Component
Let’s take a closer look at the code in the TerrapinCard
class.
class TerrapinCard extends HTMLElement {
connectedCallback() {
const div = document.createElement('div')
const pIsSwimming = document.createElement('p')
const pHeight = document.createElement('p')
const pWeight = document.createElement('p')
pIsSwimming.textContent = 'Is Swimming: Yes'
pHeight.textContent = 'Height: 0.1m'
pWeight.textContent = 'Weight: 0.2kg'
div.appendChild(pIsSwimming)
div.appendChild(pHeight)
div.appendChild(pWeight)
this.appendChild(div)
}
}
What happens here is that we utilise the connectedCallback
life-cycle hook (we will talk more about this later), which fires after the Web Component is mounted into the DOM. Think componentDidMount
in React.
When the callback is fired, we proceed to div
and p
elements, given them some content with textContent
, and then attach them to the DOM. We essentially build the DOM from scratch in our component using old-school browser JavaScript.
Back to the Future
But wait, doesn’t this all seem very… imperative? Haven’t we in the frontend community already progressed past such an approach to development with more declarative approaches – through frameworks like React, Vue or Angular – that are (usually) more productive? If so, how can Web Components be seen as being part of the future, when it really seems more like a step backwards? This was something that confused me, having abandoned plain old imperative JavaScript for React and Vue a long time ago.
We’re going to do a little segue to examine this, before going back to the code. If you are already convinced that Web Components is for you, click here to skip this section and jump back into coding.
Reusability is a way to enhance productivity
My understanding of the situation is that at its core, Web Components simply introduces some added patterns to existing browser-based plain old JavaScript. These patterns are mostly aimed at enhancing reusability and modularisation of encapsulated components in the native browser environment, without the need for all the pre/post/post-post processing tooling that most modern frontend projects inevitably need to work. Just build this one component without any of that additional tooling, and use it everywhere. Interestingly enough, in its original conception, the Web Component spec was actually designed largely for framework authors, though that did not work out too well.
From the perspective of productivity, the savings hence isn’t due to an advancement in abstractions and patterns that improve the coding experience and hence productivity, but in the idea that one can develop a single component, and use it once across multiple environments and frameworks natively. Instead of building a component first in React, then in Vue, then in Angular, then in whatever other framework that your organisation relies one, build it once as a Web Component, then use it in React/Vue/Angular/whatever.
The calculus could be something like this: Do we
- Build a component in 2 days (React) + 2 days (Vue) + 2 days (Angular) for a total of 6 days, or
- Build it less efficiently in 3 days with Web Components.
Web Components would be less efficient individually, but on the whole its supposed to provide a positive payoff.
The biggest use case
Imagine an organisation that struggles with a proliferation of front-end technology stacks. Different teams using different toolsets; some using React, others using Angular, and even a few projects in plain ole' HTML, CSS and JS. How would someone even go about building a good, extensible and scalable design system and component library that can be used by all these teams, without forcing one framework onto all of them?
The answer is meant to be Web Components. Simply create a web component in plain old JavaScript, and use it anywhere.
The implication: sticking to one framework library might be more productive for some (or many)
Imagine yours is a startup that still has the ability to constrain all frontend projects to a single framework, or yours is a more mature organisation that has already, or is in the midst of implementing, a standardisation of the frontend technology stack to a single framework. In such a case, it would make more sense to build out a component library using that framework, rather than have to deal with the productivity hit of maintaining a component library in a separate technology, in this case Web Components.
This would prevent the need for the organisation to maintain a set of skills that, firstly, is distinct from the framework the rest of the organisation relies on, and secondly, has the likely penalty of needing its developers to take a step back in terms of modern coding patterns.
Is Web Components for you?
So, in short, is Web Components for you? The answer is, it depends.
There are many approaches, and of course you could even be like this dude on Reddit, who insists that he builds full-blown sites in web components. Shrugs
Paddling around the shallows
Let’s extend a little on the code we have so far, and examine some key features of Web Components that will help you build more reusable, modularised frontend software.
Getters for Attributes
Components usually need to interact with the outside world in some form or another.
For example, what if we wanted a user of our library to be the one that defines some attributes of our Terrapin, like its height and weight? Enter attribute getters and setters.
<!DOCTYPE html>
<script>
class TerrapinCard extends HTMLElement {
connectedCallback() {
const div = document.createElement('div')
const pIsSwimming = document.createElement('p')
const pHeight = document.createElement('p')
const pWeight = document.createElement('p')
pIsSwimming.textContent = 'Is Swimming: Yes'
pHeight.textContent = `Height: ${this.getAttribute('height')}m`
pWeight.textContent = `Weight: ${this.getAttribute('weight')}kg`
div.appendChild(pIsSwimming)
div.appendChild(pHeight)
div.appendChild(pWeight)
this.appendChild(div)
}
}
customElements.define('terrapin-card', TerrapinCard)
</script>
<div>
<terrapin-card height="0.1" weight="0.3"></terrapin-card>
</div>
</html>
Here we use the this.getAttribute
method, which allows us to use attributes from the <terrapin-card>
html tag element.
Getter Observables
But what if we want to allow Web Component to respond to an occurence outside of it? For example, what if we periodically weighed our terrapin, and find out that its weight increases over time? Because connectedCallback
only fires once when the HTML Element is first mounted, we cannot rely on it to reflect updated attributes. Instead we have to rely on attribute observables, implemented through the static getter observedAttributes
and the method attributeChangedCallback
.
<!DOCTYPE html>
<script>
class TerrapinCard extends HTMLElement {
connectedCallback() {
const div = document.createElement('div')
const pIsSwimming = document.createElement('p')
const pHeight = document.createElement('p')
const pWeight = document.createElement('p')
pIsSwimming.textContent = 'Is Swimming: Yes'
pHeight.textContent = `Height: ${this.getAttribute('height')}m`
pWeight.textContent = `Weight: ${this.getAttribute('weight')}kg`
div.appendChild(pIsSwimming)
div.appendChild(pHeight)
div.appendChild(pWeight)
this.appendChild(div)
}
static get observedAttributes() {
return ['weight'];
}
attributeChangedCallback(attrName, oldValue, newValue) {
const card = document.getElementById('growable-terrapin')
if (card === null) return;
if (attrName === 'weight') {
card.children[0].children[2].textContent = `Weight: ${newValue}kg`
}
}
}
customElements.define('terrapin-card', TerrapinCard)
function growWeight(w) {
const card = document.getElementById('growable-terrapin')
const currentWeight = card.getAttribute('weight')
card.setAttribute('weight', Number(currentWeight) + w)
}
</script>
<div>
<terrapin-card id="growable-terrapin" height="0.1" weight="0.3"></terrapin-card>
<button onclick="growWeight(0.1)">Grow Weight</button>
</div>
</html>
In the above, whenever we click or tap on the “Grow Weight” button, the weight attribute is updated. Because we observe the attribute through the observedAttributes
static getter, we are able to react to its change when the attributeChangedCallback
is fired.
Why the need to specify the observedAttributes
getter, rather than have all attributes automatically observed? This is a performance optimisation, to prevent unnecessary updates when attributes we don’t care about are updated.
Setters for attributes
Setters are comparatively more straightforward, but with a little bit of additional context required. If you keep terrapins, you know that they don’t swim all day. Sometimes they crawl out onto a little rock you set in their aquarium to bask in the sun, sometimes they stop and just float around in the water. Let’s say we installed a little sensor in our aquarium to detect whether our terrapin is currently swimming or not.
<!DOCTYPE html>
<script>
class TerrapinCard extends HTMLElement {
connectedCallback() {
const div = document.createElement('div')
const pIsSwimming = document.createElement('p')
const pHeight = document.createElement('p')
const pWeight = document.createElement('p')
pIsSwimming.textContent =
`Is Swimming: ${this.isSwimmingTruthy() ? 'Yes' : 'No'}`
pHeight.textContent = `Height: ${this.getAttribute('height')}m`
pWeight.textContent = `Weight: ${this.getAttribute('weight')}kg`
div.appendChild(pIsSwimming)
div.appendChild(pHeight)
div.appendChild(pWeight)
this.appendChild(div)
this.detectSwimming()
}
isSwimmingTruthy() {
return this.getAttribute('is-swimming') === 'true'
}
detectSwimming() {
setTimeout(() => {
const isSwimming = this.isSwimmingTruthy()
this.setAttribute('is-swimming', !isSwimming)
this.detectSwimming()
}, Math.random() * 5000)
}
static get observedAttributes() {
return ['is-swimming'];
}
attributeChangedCallback(attrName, oldValue, newValue) {
const card = document.getElementById('growable-terrapin')
if (card === null) return;
if (attrName === 'is-swimming') {
card.children[0].children[0].textContent =
`Is Swimming: ${newValue === 'true' ? 'Yes': 'No'}`
}
}
}
customElements.define('terrapin-card', TerrapinCard)
</script>
<div>
<terrapin-card
id="growable-terrapin"
height="0.1"
weight="0.3"
is-swimming="true"
></terrapin-card>
</div>
</html>
Here detectSwimming
is simply a setTimeout
approach to toggling whether the terrapin is swimming or not. We toggle this behavior through the simple function setAttribute
, which in this case sets the is-swimming
attribute on the HTML Element. This reflects to the outside environment that the terrapin is swimming. You can see this behavior at work by opening up your browser console and inspecting the <terrapin-card>
HTML element. We can then listen on attribute changes in the Web Component from the outside.
There is something else here however that is important: observedAttributes
. Notice that we have to define is-swimming
in this static getter, because otherwise the attributeChangedCallback
will not fire even if we set the is-swimming
attribute programmatically from within the TerrapinCard Web Component. The flow here is always event-driven – if we want to react to any change in attribute, whether this change is effected from outside or within the Web Component, we have to first observe it.
Property reflection
Notice the isSwimmingTruthy
method we define in the above example:
isSwimmingTruthy() {
return this.getAttribute('is-swimming') === 'true'
}
This method exists attributes in Web Components are always of the type string
. Dealing with this limitation using transform methods like here where we convert the string
into a boolean
that our component requires works for simple use cases. But this limitation presents a larger problem when we start to work with richer data like array and objects.
A common recommendation on the web when dealing with array and objects is to use JSON.stringify
to serialize data into a string, pass it in as an attribute, then deserialize it in the Web Component with JSON.parse
. One can already start to see the problem with this approach: it inevitably facing performance constraints, especially when dealing with data structure with a larger memory footprint.
The solution to this is to avoid using attributes in such cases, and instead rely on property reflection. This method relies on Property getters and setters native to the JavaScript specification, and is the recommended way for Web Components to accept rich data.
Let’s say we wanted to pass in the attributes isSwimming
, height
and weight
as a JSON syntax object, a common data structure when working with REST APIs. We could use a property setter to pass in this data.
<!DOCTYPE html>
<script>
class TerrapinCard extends HTMLElement {
connectedCallback() {
const div = document.createElement('div')
const pIsSwimming = document.createElement('p')
const pHeight = document.createElement('p')
const pWeight = document.createElement('p')
div.appendChild(pIsSwimming)
div.appendChild(pHeight)
div.appendChild(pWeight)
this.appendChild(div)
}
set data ({ isSwimming, height, weight }) {
const card = document.getElementById('terrapin')
if (card === null) return;
card.children[0].children[0].textContent = `Is Swimming: ${isSwimming ? 'Yes': 'No'}`
card.children[0].children[1].textContent = `Height: ${height}m`
card.children[0].children[2].textContent = `Weight: ${weight}kg`
}
}
customElements.define('terrapin-card', TerrapinCard)
</script>
<div>
<terrapin-card id="terrapin"></terrapin-card>
</div>
<script>
const card = document.getElementById('terrapin')
card.data = {
isSwimming: true,
height: 0.1,
weight: 0.3,
}
</script>
</html>
Our property setter is triggered when the property is set using card.data = {...}
. The property setter itself is defined with the function signature:
set propertyName(value) {...}
Though more verbose, this allows a lot more power in working with properties and data more efficiently in Web Components.
Custom Events
Listening on attribute changes, though clever, is not a commonly used pattern in practice. There is however an alternative approach, defining custom events.
<!DOCTYPE html>
<script>
class TerrapinCard extends HTMLElement {
connectedCallback() {
const div = document.createElement('div')
const pIsSwimming = document.createElement('p')
const pHeight = document.createElement('p')
const pWeight = document.createElement('p')
pIsSwimming.textContent = `Is Swimming: ${this.isSwimmingTruthy() ? 'Yes' : 'No'}`
pHeight.textContent = `Height: ${this.getAttribute('height')}m`
pWeight.textContent = `Weight: ${this.getAttribute('weight')}kg`
div.appendChild(pIsSwimming)
div.appendChild(pHeight)
div.appendChild(pWeight)
this.appendChild(div)
this.detectSwimming()
}
isSwimmingTruthy() {
return this.getAttribute('is-swimming') === 'true'
}
detectSwimming() {
setTimeout(() => {
const isSwimming = this.isSwimmingTruthy()
this.setAttribute('is-swimming', !isSwimming)
this.dispatchEvent(new CustomEvent("toggle", {
detail: {
isSwimming: !isSwimming,
}
}))
this.detectSwimming()
}, Math.random() * 5000)
}
static get observedAttributes() {
return ['is-swimming'];
}
attributeChangedCallback(attrName, oldValue, newValue) {
const card = document.getElementById('growable-terrapin')
if (card === null) return;
if (attrName === 'is-swimming') {
card.children[0].children[0].textContent =
`Is Swimming: ${newValue === 'true' ? 'Yes': 'No'}`
}
}
}
customElements.define('terrapin-card', TerrapinCard)
</script>
<div>
<terrapin-card
id="growable-terrapin"
height="0.1"
weight="0.3"
is-swimming="true"
></terrapin-card>
</div>
<script defer>
const el = document.getElementById("growable-terrapin")
el.addEventListener("toggle", (e) => {
console.log(e.detail.isSwimming)
})
</script>
</html>
This looks complicated, but there are only 2 additions to our code.
First, we have our Web Component dispatch a custom event, using the following syntax.
this.dispatchEvent(new CustomEvent("toggle", {
detail: {
isSwimming: !isSwimming,
}
}))
Secondly, we listen on the Web Component by selecting it with a query selector, then attaching an event listener to it with. We do this is a separate <script defer>
tag, to ensure that the event listener is only attached after the document is ready.
<script defer>
const el = document.getElementById("growable-terrapin")
el.addEventListener("toggle", (e) => {
console.log(e.detail.isSwimming)
})
</script>
With this, we are able to respond to events emitted by our Web Component.
Life-cycle Hooks
Life-cycle hooks are a central part of Web Components, and in fact we have already seen 2 of them in our examples above: connectedCallback
and attributeChangedCallback
.
According to specification parlance, these are called custom element reactions, but its easier to view them as the equivalent of life-cycle hooks in React. There are 5 of them in total, though the custom elements specification, one of the underpinning specifications of Web Components, lists and describes them effectively enough for me. I will list them out here for completeness-sake:
constructor
connectedCallback
disconnectedCallback
attributeChangedCallback
adoptedCallback
All-in-all a very simple set of hooks, that provide a solid amount of functionality. You can find explanations for them here
Wait a minute: one of the underpinning specifications of Web Components? What does that even mean? Do you mean that there are other specifications that underpin Web Components? But isn’t Web Components a specifiation?
This sudden flash of confusion brings us to an essential fact about Web Components I’ve avoided discussing so far.
A deeper dive – Web Components as a meta-specification
In truth, web components are actually a meta-specification. You can think of it as a specialised toolbox that brings together multiple tools (specifications) to achieve a specific set of goals and objectives.
The tools in question consist of 4 specifications. Let’s tackle each one in turn.
1. The Custom Elements specification
In fact, what we have described so far, has been the feature set of the Custom Elements specification. This is the specification that “allow[s] developers to define new HTML tags, extend existing ones, and create reusable web components.”
And the way this is allowed is through the class CustomComponent extends HTMLElement {...}
pattern that we have been using in our Web Component examples. This specification also defines the getters, setters, and life-cycle hooks we have been working with so far.
2. The shadow DOM specification
The Shadow DOM is a related beast that achieves a different goal: encapsulation (read: isolation). The Shadow DOM specification allows developers to build and inject isolated subtrees into the DOM that cannot be interfered with by the JavaScript and CSS running in the page and wider DOM. In effect, this enables encapsulation of styles and behavior through isolation.
Before we begin to understand the need for the Shadow DOM in Web Components, we need to first take a step back to examine the question of styling in Web Components.
Styling
So far we have dealt a lot with HTML markup and JavaScript. In fact, we have seen that Web Components can be instantiated like any JavaScript class, and in fact utilises many of the plain old JavaScript approaches non-framework users are very familiar with. But we cannot discuss a front-end specification without delving into the topic of styling, which is the CSS in that trifecta of HTML, CSS and JavaScript so familiar to front-end developers.
Let’s take the simplest Web Component we have so far, and apply a little styling.
<!DOCTYPE html>
<script>
class TerrapinCard extends HTMLElement {
connectedCallback() {
const div = document.createElement('div')
const pIsSwimming = document.createElement('p')
const pHeight = document.createElement('p')
const pWeight = document.createElement('p')
pIsSwimming.textContent = 'Is Swimming: Yes'
pHeight.textContent = 'Height: 0.1m'
pWeight.textContent = 'Weight: 0.2kg'
div.appendChild(pIsSwimming)
div.appendChild(pHeight)
div.appendChild(pWeight)
this.appendChild(div)
}
}
customElements.define('terrapin-card', TerrapinCard)
</script>
<div>
<terrapin-card></terrapin-card>
</div>
<style>
p {
color: blue;
}
</style>
</html>
We see that you can style it like any other HTML component. In this case we select the p
tag, and make all the text blue.
What happens, however, if we add paragraphs outside of the <terrapin-card></terrapin-card>
in the HTML?
<!DOCTYPE html>
<script>
class TerrapinCard extends HTMLElement {
connectedCallback() {
const div = document.createElement('div')
const pIsSwimming = document.createElement('p')
const pHeight = document.createElement('p')
const pWeight = document.createElement('p')
pIsSwimming.textContent = 'Is Swimming: Yes'
pHeight.textContent = 'Height: 0.1m'
pWeight.textContent = 'Weight: 0.2kg'
div.appendChild(pIsSwimming)
div.appendChild(pHeight)
div.appendChild(pWeight)
this.appendChild(div)
}
}
customElements.define('terrapin-card', TerrapinCard)
</script>
<div>
<p>I am above the terrapin.</p>
<terrapin-card></terrapin-card>
<p>I am below the terrapin.</p>
</div>
<style>
p {
color: blue;
}
</style>
</html>
To the experienced eye, naturally the styles of all the paragraphs will be altered. There is nothing special about the selector to only turn the text in the TerrapinCard blue.
What we could do is select the <terrapin-card></terrapin-card>
and apply the blue color styles only on it, that way the rest of the paragraphs outside of it are unaffected.
<!DOCTYPE html>
<script>
class TerrapinCard extends HTMLElement {
connectedCallback() {
const div = document.createElement('div')
const pIsSwimming = document.createElement('p')
const pHeight = document.createElement('p')
const pWeight = document.createElement('p')
pIsSwimming.textContent = 'Is Swimming: Yes'
pHeight.textContent = 'Height: 0.1m'
pWeight.textContent = 'Weight: 0.2kg'
div.appendChild(pIsSwimming)
div.appendChild(pHeight)
div.appendChild(pWeight)
this.appendChild(div)
}
}
customElements.define('terrapin-card', TerrapinCard)
</script>
<div>
<p>I am above the terrapin.</p>
<terrapin-card></terrapin-card>
<p>I am below the terrapin.</p>
</div>
<style>
terrapin-card {
color: blue;
}
</style>
</html>
This certainly gets the job done, but then the styles of the component are necessarily being decided from outside of it. Any other residual styles that alter p
in general, or even div
are going to somehow pollute the styles of the TerrapinCard
incidentally.
Remember we mentioned that the Shadow DOM allows the encapsulation of styles and behavior? This is exactly how the Shadow DOM contributes to building reusable Web Components.
Using the Shadow DOM as a Shield
<!DOCTYPE html>
<script>
class TerrapinCard extends HTMLElement {
constructor() {
super()
this.attachShadow({mode: 'open'})
}
connectedCallback() {
const div = document.createElement('div')
const pIsSwimming = document.createElement('p')
const pHeight = document.createElement('p')
const pWeight = document.createElement('p')
pIsSwimming.textContent = 'Is Swimming: Yes'
pHeight.textContent = 'Height: 0.1m'
pWeight.textContent = 'Weight: 0.2kg'
div.appendChild(pIsSwimming)
div.appendChild(pHeight)
div.appendChild(pWeight)
this.shadowRoot.append(div)
}
}
customElements.define('terrapin-card', TerrapinCard)
</script>
<div>
<p>I am above the terrapin.</p>
<terrapin-card></terrapin-card>
<p>I am below the terrapin.</p>
</div>
<style>
p {
color: blue;
}
</style>
</html>
Here we use the constructor
life-cycle hook, and call the attachShadow
method in it. This implicitly creates a shadow root that we can access with this.shadowRoot
.
The power of this shadow root is observable in the style tag: notice how we highlight that all paragraphs should have blue text – but when we render the HTML file in a browser, only text outside of the TerrapinCard
Web Component is colored blue. The styles of the Web Component have essentially been severed from the rest of the DOM, through encapsulation by the Shadow DOM. Think Severance, just without the creepy immorality.
Using styles in a Shadow Root
What if we want to style the Web Component itself? We do this by programmatically generating and attaching a CSSStyleSheet inside of the shadow root.
<!DOCTYPE html>
<script>
class TerrapinCard extends HTMLElement {
constructor() {
super()
this.attachShadow({mode: 'open'})
}
connectedCallback() {
const div = document.createElement('div')
const pIsSwimming = document.createElement('p')
const pHeight = document.createElement('p')
const pWeight = document.createElement('p')
pIsSwimming.textContent = 'Is Swimming: Yes'
pHeight.textContent = 'Height: 0.1m'
pWeight.textContent = 'Weight: 0.2kg'
div.appendChild(pIsSwimming)
div.appendChild(pHeight)
div.appendChild(pWeight)
this.shadowRoot.append(div)
const sheet = new CSSStyleSheet()
sheet.replaceSync("p { color: red }")
this.shadowRoot.adoptedStyleSheets = [sheet]
}
}
customElements.define('terrapin-card', TerrapinCard)
</script>
<div>
<p>I am above the terrapin.</p>
<terrapin-card></terrapin-card>
<p>I am below the terrapin.</p>
</div>
<style>
p {
color: blue;
}
</style>
</html>
Here we instantiate a CSSStyleSheet, and attach it to the shadowRoot with this.shadowRoot.adoptedStyleSheets = [sheet]
.
Notice how the colors of paragraphs outside the Web Component remain blue, but those inside are red. A clear separation of styles.
This also works for JavaScript
Remember how the Shadow DOM encapsulates both style and behavior? It turns out that the encapsulation works as well to keep JavaScript from interfering both ways.
<!DOCTYPE html>
<script>
class TerrapinCard extends HTMLElement {
constructor() {
super()
this.attachShadow({mode: 'open'})
}
connectedCallback() {
const div = document.createElement('div')
const pIsSwimming = document.createElement('p')
const pHeight = document.createElement('p')
const pWeight = document.createElement('p')
pIsSwimming.textContent = 'Is Swimming: Yes'
pHeight.textContent = 'Height: 0.1m'
pWeight.textContent = 'Weight: 0.2kg'
div.appendChild(pIsSwimming)
div.appendChild(pHeight)
div.appendChild(pWeight)
this.shadowRoot.append(div)
}
}
customElements.define('terrapin-card', TerrapinCard)
function changeSomeParagraphs() {
document.querySelectorAll('p').forEach(el => {
el.innerText = "New Text"
})
}
</script>
<div>
<p>I am above the terrapin.</p>
<terrapin-card></terrapin-card>
<p>I am below the terrapin.</p>
<button onclick="changeSomeParagraphs()">Change all text</button>
</div>
</html>
Click on “Change all text”, and voilà! Only text outside of the component is selected to be changed. Like styling, this separation works both ways.
Mode closed vs open
There is one thing I need to address that confused me at some point as well: What’s the difference between the ‘open’ and ‘closed’ modes of the shadow root? You can see this from this.attachShadow({mode: 'open'})
.
Well, none apparently, except for encouraging a pseudo-encapsulation. As far as I understand it, it’s a way for library authors to hint to developers not to touch the shadow root. This article helped me greatly to understand this, and perhaps it might be of service to you as well.
A more declarative pattern with template literals
While still on the topic of the Shadow DOM, another interesting feature that it affords is the ability to use template literals to make our code more declarative.
<!DOCTYPE html>
<script>
class TerrapinCard extends HTMLElement {
constructor() {
super()
this.attachShadow({mode: 'open'})
this.shadowRoot.innerHTML = `
<div>
<p>Is Swimming: <span id="is-swimming"></span></p>
<p>Height: <span id="height"></span></p>
<p>Weight: <span id="weight"></span></p>
</div>
`
}
connectedCallback() {
this.shadowRoot.getElementById("is-swimming").textContent =
this.getAttribute('is-swimming') === 'true' ? 'Yes' : 'No'
this.shadowRoot.getElementById("height").textContent =
this.getAttribute('height')
this.shadowRoot.getElementById("weight").textContent =
this.getAttribute('weight')
}
}
customElements.define('terrapin-card', TerrapinCard)
</script>
<div>
<terrapin-card
height="0.1"
weight="0.3"
is-swimming="true"
></terrapin-card>
</div>
</html>
All we have done here is defined our HTML markup declaratively using a template literal, rather than constructing it with a series of imperative commands. We then attach this to the shadow root via its innerHTML
property. This approach, being declarative, might be more familiar to users of frameworks like React/Vue/Angular.
We then use getElementById
on the shadow root to gain access to the relevant spans, and set their respective values in connectedCallback
. In fact, we can do this selection using any of the common selectors, including querySelectorAll
and getElementsByTagName
.
Why do we have to set the values only in the connectedCallback
hook? That’s because according to the specification, attributes are not available yet at the constructor
hook.
Some tutorials also generate a div
first, attach it to the shadow root, then set the innerHTML of that div. Personally I haven’t come across a reason to do that.
Slots
One of the key possibilities opened up by the Shadow DOM is the possibility of using slots, which is a part of the Shadow DOM API. Slots allow developers to hoist child tags into their Web Components and render them, and are the rough equivalent of the {{ children }}
pattern familiar to React developers.
Slots also greatly complement our template literal approach to declaring HTML elements.
Let’s say we wanted to include a name and description of our Terrapin as part of its card, but didn’t want to do this through attributes.
<!DOCTYPE html>
<script>
class TerrapinCard extends HTMLElement {
constructor() {
super()
this.attachShadow({mode: 'open'})
this.shadowRoot.innerHTML = `
<div>
<slot></slot>
<p>Is Swimming: <span id="is-swimming"></span></p>
<p>Height: <span id="height"></span></p>
<p>Weight: <span id="weight"></span></p>
</div>
`
}
connectedCallback() {
this.shadowRoot.getElementById("is-swimming").textContent =
this.getAttribute('is-swimming') === 'true' ? 'Yes' : 'No'
this.shadowRoot.getElementById("height").textContent =
this.getAttribute('height')
this.shadowRoot.getElementById("weight").textContent =
this.getAttribute('weight')
}
}
customElements.define('terrapin-card', TerrapinCard)
</script>
<div>
<terrapin-card height="0.1" weight="0.3" is-swimming="true">
<h3>Shell</h3>
<p>My dear terrapin, with your magnificent shell. Lover of lettuce and sashimi. I hope you get to swim to your heart's content.</p>
</terrapin-card>
</div>
<style>
p {
text-decoration: underline;
}
</style>
</html>
All we needed to do was include whatever custom elements we wanted as children in the <terrapin-card></terrapin-card>
HTML markup, and insert a corresponding <slot></slot>
tag in the Web Component.
We can also now style the children we inject without interfering with the styling of the TerrapinCard Web Component itself.
Named Slots
We can even use named slots to organise multiple children in the Web Component layout. Let’s say we wanted the description to be all the way at the bottom, while Shell’s name continues sitting at the top.
<!DOCTYPE html>
<script>
class TerrapinCard extends HTMLElement {
constructor() {
super()
this.attachShadow({mode: 'open'})
this.shadowRoot.innerHTML = `
<div>
<slot name="pet-name">Your pet name should go here</slot>
<p>Is Swimming: <span id="is-swimming"></span></p>
<p>Height: <span id="height"></span></p>
<p>Weight: <span id="weight"></span></p>
<slot name="pet-description"></slot>
</div>
`
}
connectedCallback() {
this.shadowRoot.getElementById("is-swimming").textContent =
this.getAttribute('is-swimming') === 'true' ? 'Yes' : 'No'
this.shadowRoot.getElementById("height").textContent =
this.getAttribute('height')
this.shadowRoot.getElementById("weight").textContent =
this.getAttribute('weight')
}
}
customElements.define('terrapin-card', TerrapinCard)
</script>
<div>
<terrapin-card height="0.1" weight="0.3" is-swimming="true">
<h3 slot="pet-name">Shell</h3>
<p slot="pet-description">My dear terrapin, with your magnificent shell. Lover of lettuce and sashimi. I hope you get to swim to your heart's content.</p>
</terrapin-card>
</div>
<style>
p {
text-decoration: underline;
}
</style>
</html>
By using slot naming syntax, for example with the property slot="pet-name"
, we acquire the ability to layout the children of our component in any number of configurable ways – so long as we know the name of the slots.
In addition, notice how we added the default value “Your pet name should go here”? This allows us to set default values for our slots in the event that some of them are not named.
A note on Custom Events with the Shadow DOM
When working with the Shadow DOM, there will come a time when we will need elements inside it to emit events to the outside world. Earlier in the section on Custom Events, we introduced a this.dispatchEvent(new CustomEvent('event-name'), { ... })
method, which we call to emit a custom event that can be picked up outside of the component.
The Shadow DOM however generally disallows events to propagate past its boundary, in the interest of encapsulation. This prevents stray events from bouncing around outside of the Web Component, especially events meant to be kept within its boundaries for internal use only.
In such a case, the solution is simple, we need to introduce a property to the CustomEvent constructor options: composed. This property specifies whether an event should bubble across the shadow DOM to the light DOM. We use it like so:
this.dispatchEvent(new CustomEvent('event-name'), {
composed: true,
})
This allows the custom event to propagate beyond the boundary of the Shadow DOM, to be picked up by the outside world.
3. The HTML Template specification
The HTML Template Specification, implemented via the HTML Template Element, is a spec that allows the declaration of HTML fragments, that can be cloned or inserted into documents by scripts.
Anything in HTML can be declared to be part of a template, simply by enclosing it in <template></template>
tags.
<template>
<h3>I am a Template Title</h3>
<p>I am some words</p>
<p>I am more words</p>
</template>
Rendering a template
Let’s try to render a template using plain HTML:
<!DOCTYPE html>
<div>
<template>
<h3>I am a Template Title</h3>
<p>I am some words</p>
<p>I am more words</p>
</template>
</div>
</html>
Wait a minute, absolutely nothing is rendering! It’s completely blank.
That’s because a template element represents literally nothing in a rendering, as opposed to normal HTML Elements in the DOM. In that case, what’s the point in having it?
Using the template with the Shadow DOM
The value of the template specification lies in its ability to allow us to define HTML Elements without having them rendered and visible to the user. This allows us to clone them for use in our Web Components, which improves performance by reducing HTML parsing costs.
Remember the template literal pattern we used earlier with our Shadow DOM? We can replace it with the HTML template element.
<!DOCTYPE html>
<template id="terrapin-card-template">
<div>
<p>Is Swimming: <span id="is-swimming"></span></p>
<p>Height: <span id="height"></span></p>
<p>Weight: <span id="weight"></span></p>
</div>
</template>
<script>
class TerrapinCard extends HTMLElement {
constructor() {
super()
this.attachShadow({mode: 'open'})
const templateNode = document.getElementById('terrapin-card-template')
const clone = templateNode.content.cloneNode(true)
this.shadowRoot.append(clone)
}
connectedCallback() {
this.shadowRoot.getElementById("is-swimming").textContent =
this.getAttribute('is-swimming') === 'true' ? 'Yes' : 'No'
this.shadowRoot.getElementById("height").textContent =
this.getAttribute('height')
this.shadowRoot.getElementById("weight").textContent =
this.getAttribute('weight')
}
}
customElements.define('terrapin-card', TerrapinCard)
</script>
<div>
<terrapin-card height="0.1" weight="0.3" is-swimming="true"></terrapin-card>
</div>
</html>
Here we define our HTML markup in <template>
. We then select it in the constructor
hook using the “terrapin-card-template” id, clone its content, then append it to the shadow root.
This also allows us to retain our declarative approach to Web Components, while still allowing us to utilise advanced programming assistive tools like code completion and automated linting.
A much better way to style
Template elements also open up the ability for us to handle styling declaratively. Rather than defining a stylesheet programmatically and attaching it to the shadow root like we did earlier, we can simply define a style
tag in the template, and put the styling we need for the Web Component in it.
<!DOCTYPE html>
<template id="terrapin-card-template">
<div>
<p>Is Swimming: <span id="is-swimming"></span></p>
<p>Height: <span id="height"></span></p>
<p>Weight: <span id="weight"></span></p>
</div>
<style>
p {
color: blue;
}
</style>
</template>
<script>
class TerrapinCard extends HTMLElement {
constructor() {
super()
this.attachShadow({mode: 'open'})
const templateNode = document.getElementById('terrapin-card-template')
const clone = templateNode.content.cloneNode(true)
this.shadowRoot.append(clone)
}
connectedCallback() {
this.shadowRoot.getElementById("is-swimming").textContent =
this.getAttribute('is-swimming') === 'true' ? 'Yes' : 'No'
this.shadowRoot.getElementById("height").textContent =
this.getAttribute('height')
this.shadowRoot.getElementById("weight").textContent =
this.getAttribute('weight')
}
}
customElements.define('terrapin-card', TerrapinCard)
</script>
<div>
<p>I am above the card.</p>
<terrapin-card height="0.1" weight="0.3" is-swimming="true"></terrapin-card>
<p>I am below the card.</p>
</div>
</html>
Notice how, once again, the styles for the Web Component are separated from that of the wider DOM.
4. The ES Module specification
This specification defines a standardised module system for JavaScript. We can take advantage of this specification to import Web Components defined in standalone JavaScript files, and use them directly in our HTML markup.
Implementing the module system for Web Components
For this to work, we need two separate files, one for JS (the module) and one for HTML.
// terrapin-card.js
const templateString = `
<div>
<p>Is Swimming: <span id="is-swimming"></span></p>
<p>Height: <span id="height"></span></p>
<p>Weight: <span id="weight"></span></p>
</div>
<style>
p {
color: blue;
}
</style>
`
const template = document.createElement('template')
template.innerHTML = templateString
class TerrapinCard extends HTMLElement {
constructor() {
super()
this.attachShadow({mode: 'open'})
const clone = template.content.cloneNode(true)
this.shadowRoot.append(clone)
}
connectedCallback() {
this.shadowRoot.getElementById("is-swimming").textContent = this.getAttribute('is-swimming') === 'true' ? 'Yes' : 'No'
this.shadowRoot.getElementById("height").textContent = this.getAttribute('height')
this.shadowRoot.getElementById("weight").textContent = this.getAttribute('weight')
}
}
customElements.define('terrapin-card', TerrapinCard)
<!-- main.html -->
<!DOCTYPE html>
<script type="module" src="./terrapin-card.js"></script>
<div>
<p>I am above the card.</p>
<terrapin-card height="0.1" weight="0.3" is-swimming="true"></terrapin-card>
<p>I am below the card.</p>
</div>
</html>
To avoid a CORS-issue with modern-day browsers that prevents loading scripts from file://
due to a security vulnerability, we need to start up a server to serve the two above files on localhost. The quickest way is as follows, but feel free to use a method you are more comfortable with:
cd path/to/html/and/js/files/folder
npx browser-sync start --server
This serves your html and js files at the specified localhost url. Assuming the port is at :3000
, we can point our browser to http://localhost:3000/main.html, and see the TerrapinCard
render as expected.
Explanation
The ES Module specification, more widely-known for its definition of the import
and export
syntax, also provides a standardised way of importing JavaScript modules using script
tags, either within a script
, or as an external script.
<!-- within a script -->
<script type="module">
import './terrapin-card.js'
</script>
<!-- as an external script -->
<script type="module" src="./terrapin-card.js"></script>
What happened here is that we defined our TerrapinCard
in a JavaScript file, and import it with a script with type="module"
. type=module
tells the browser that the JavaScript file we are importing should be imported as an ES6 module. This imports the Web Component, allowing us to use it in our HTML markup in the usual way.
Some roughness around the edges
Notice how we can no longer define our template directly using the <template></template>
syntax. Instead we have to programmatically create it, then attach its contents via a template literal. That’s obviously because this is a JavaScript file, but also because there currently isn’t any way to define HTML fragments for import into our main.html
document.
In all honesty, this feels like a little bit of an oversight. It is a minor point in the grand scheme of things, but in a sense, 2 of the 4 specifications in the Web Components meta-specification seem to reflect certain incompatibilities, casting a shade on the wider ecosystem. Certainly, the Template Specification is still being utilised, but we have to almost hack it into the Web Component using a template literal, which kinda seems out of place.
The upside to this approach however is that our component is self-contained in a single file, and the template is locally scoped through the template
variable, the latter of which prevents scope pollution in the global namespace which would be a problem if we were to have multiple templates named in a conflicting manner.
And with this, we have a fully-encapsulated Web Component ready for import.
Conclusion
In this article we started from zero knowledge of Web Components, to rendering our first Web Component in the browser. We then explored different features, and subsequently ways of building and designing Web Components. Throughout the process we have attempted to build a foundational understanding of the specifications that underpin Web Components, and their implications and limitations.
Two questions jumped out at me as we worked through the article.
Firstly, can Web Components work for a frameworkless approach to frontend development? Through the power of encapsulation enabled by the various specifications, chief among them ES6 modules, I would argue that it is technically possible and relatively feasible.
Secondly, can Web Components work for sharing code across multiple frontend stacks, including multiple frameworks? Unfortunately, this is not a a question that can be answered at the level of knowledge presented in this article, and is a problem that we will have to examine separately in a different article.