Demystifying Web Components
Overview
Components are individual pieces of code which can be used in different contexts; they often represent a reusable piece of a design, such as a button or badge, and sometimes even as complex as more prominent elements like carousels and lightboxes. One of the many goals of a component is to bring consistency and cohesion, preventing multiple one-off implementations of the same thing across a codebase or several codebases. Components come in many flavours. For instance, they can be written in a framework like React, Angular, or just plain CSS. These are all great options, but these days, I've gravitated more towards Web Components.
But Why Web Components?
A Web Component is a reusable custom HTML element that encapsulates its functionality and styling; they are built using a set of web platform APIs that are part of the HTML and DOM specifications, including Custom Elements, Shadow DOM, and HTML Templates. Web components can be used in any JavaScript framework or library or with plain HTML and JavaScript, making them highly interoperable.
<daily-greeting></daily-greeting>
Consider Web Components in scenarios where you maintain multiple applications, especially if you have an active design system with a component library. Maintaining a component library written in a specific framework can often result in future applications being built in that same framework, as it's easier to integrate with your design system, even if that framework isn't the right choice for the project. Alternatively, suppose you're creating a new design system with existing legacy applications. In that case, the technology may already be all over the place, making Web Components an attractive choice.
There are more reasons besides interoperability, though. One of the big ones is the Shadow DOM. When used, components placed on a page become shielded from exterior factors, meaning external stylesheets and general document queries cannot mutate your components. This level of protection can be great for a design system, as an element will always look the same way no matter how and where you use it. If you need to support some degree of customization, you can leverage CSS variables as, by nature, they pierce the Shadow DOM. You can also use the part
pseudo-element to allow external CSS to target specific things.
daily-greeting::part(message) {
color: #333;
}
daily-greeting {
--message-color: #333;
}
Additionally, the Shadow DOM provides the ability to build compositional components with the help of slots. Using these, you can provide an API for consumers to slot their elements into particular parts of a component. Slots are helpful as they allow for customization without being too restrictive. For example, suppose you want to support a header by providing it via a slot instead of an attribute. In that case, you can allow consumers to give a header tag relevant to where the component appears on their page to keep their DOM structure in a logical order. They also allow you to slot whole other components into one another, enabling you to build smaller atomic pieces that form more significant components and patterns when pieced together.
<section>
<h1>My Page</h1>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
<daily-greeting>
<h2 slot="header">Hello, world!</h2>
<daily-icon slot="icon" icon="thumbs"><daily-icon>
</daily-greeting>
</section>
Ultimately, adopting Web Components depends on your current and future scenarios. It's easy to become vendor-locked, and Web Components could be a way to avoid that.
Building a Web Component
So, how are these built? You'd be wrong to expect them to be overly complex and niche. Here's an example of a basic component that uses the connectedCallback
lifecycle to show a greeting.
class DailyGreeting extends HTMLElement {
connectedCallback() {
this.render()
}
render() {
let date = new Date()
let currentHour = date.getHours()
let greeting = ''
if (currentHour < 12) {
greeting = 'Good morning!'
} else if (currentHour < 18) {
greeting = 'Good afternoon!'
} else {
greeting = 'Good evening!'
}
this.shadowRoot.innerHTML = `
<div part="message">
${greeting}
</div>
`
}
}
window.customElements.define('daily-greeting', DailyGreeting)
We can easily support slots by adding a slot
element. In the following example, I've added a default slot to a component which allows you to insert content to create a scrollable list. The following example includes more than slots; it covers several concepts, including attributes.
class ScrollSnapCarousel extends HTMLElement {
static get observedAttributes() {
return ['alignment']
}
constructor() {
super()
this.shadow = this.attachShadow({ mode: 'open' })
this.alignment = 'start';
this.scrollToNextPage = this.scrollToNextPage.bind(this)
this.scrollToPreviousPage = this.scrollToPreviousPage.bind(this)
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'alignment') {
this.alignment = newValue
}
}
connectedCallback() {
this.render()
this.gallery = this.shadowRoot.querySelector('#paginated-gallery')
this.galleryScroller = this.gallery.querySelector('.gallery-scroller')
this.calculateGalleryItemSize()
this.gallery
.querySelector('button.next')
.addEventListener('click', this.scrollToNextPage)
this.gallery
.querySelector('button.previous')
.addEventListener('click', this.scrollToPreviousPage)
window.addEventListener('resize', this.calculateGalleryItemSize)
}
disconnectedCallback() {
window.removeEventListener('resize', this.calculateGalleryItemSize)
}
calculateGalleryItemSize() {
const slotElement = this.galleryScroller.querySelector('slot')
const nodes = slotElement.assignedNodes({ flatten: true })
const firstSlottedElement = nodes.find(
(node) => node.nodeType === Node.ELEMENT_NODE,
)
this.galleryItemSize = firstSlottedElement.clientWidth
}
scrollToPreviousPage() {
this.galleryScroller.scrollBy(-this.galleryItemSize, 0)
}
scrollToNextPage() {
this.galleryScroller.scrollBy(this.galleryItemSize, 0)
}
render() {
this.shadowRoot.innerHTML = `
<style>
</style>
<div id="paginated-gallery" class="gallery">
<button class="previous" aria-label="Previous"></button>
<button class="next" aria-label="Next" ></button>
<div class="gallery-scroller">
<slot></slot>
</div>
</div>
`
}
}
window.customElements.define('scroll-snap-carousel', ScrollSnapCarousel)
You can project content into specific areas by giving the slot a name. For example, if we wanted to add some disclaimer text beneath the carousel, we could do so by adding another slot for it with the name disclaimer
.
render() {
this.shadowRoot.innerHTML = `
<style>
</style>
<div id="paginated-gallery" class="gallery">
<button class="previous" aria-label="Previous"></button>
<button class="next" aria-label="Next" ></button>
<div class="gallery-scroller">
<slot></slot>
</div>
</div>
<slot name="disclaimer"></slot>
`
}
With the custom element registered, all you'd need to do is place the following in the HTML to use the component.
<scroll-snap-carousel alignment="start">
<iframe src="https://www.youtube.com/embed/3ZTvsUeQkOM?si=iNSnBcZRKWsMUB2e" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
<iframe src="https://www.youtube.com/embed/7Dr5LW9xnSs?si=aLyux8R2QGk61XwU" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
<iframe src="https://www.youtube.com/embed/31lbp1dolAI?si=B1SYEzLmlZ8QN3pG" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
<iframe src="https://www.youtube.com/embed/onthvMAIpUI?si=XZK9y1OkfAhMJUFY" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
<iframe src="https://www.youtube.com/embed/Ij-1kXYKD3c?si=ha_b0VuYevv-6q-V" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
<iframe src="https://www.youtube.com/embed/RFdLLDmTTk8?si=1RVxxa3Hw-nL8ph8" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
<span slot="disclaimer">Videos by <a href="">RayRay</a></span>
</scroll-snap-carousel>
This is what our component looks like. You can test it on Codepen, too, if you'd like to mess around with the API and get a more in-depth explanation of what all the methods do. If you have never encountered a Web Component before, especially one that utilizes the Shadow DOM, open up your browser debugger to inspect it!
The Shadow DOM Is Optional
You don't need to use the Shadow DOM; it's optional. Not using the Shadow DOM could be a good choice if you intend to provide smaller components with the expectation that others will apply additional styling or logic and don't want to offer part selectors or CSS variables for everything. However, removing the Shadow DOM also eliminates the ability to slot content, which is a significant loss and could make building specific components that rely on composition more challenging.
Using the previous example, I worked around the lack of slots by manipulating the tree with document.createDocumentFragment
and appending child items in the correct spot after the component connects. It could be better, but it does work. As with anything, use your best judgment based on your needs to determine whether you should leverage the Shadow DOM. It can be more of a hindrance not to use it if you have a sound system for supporting variables as you lose the encapsulation benefits.
class ScrollSnapCarousel extends HTMLElement {
static get observedAttributes() {
return ['alignment']
}
constructor() {
super()
this.alignment = 'start'
this.scrollToNextPage = this.scrollToNextPage.bind(this)
this.scrollToPreviousPage = this.scrollToPreviousPage.bind(this)
this.calculateGalleryItemSize = this.calculateGalleryItemSize.bind(this)
this.ingestChildren = this.ingestChildren.bind(this)
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'alignment') {
this.alignment = newValue
}
}
connectedCallback() {
this.ingestChildren()
this.render()
this.gallery = this.querySelector('#paginated-gallery')
this.galleryScroller = this.gallery.querySelector('.gallery-scroller')
this.galleryScroller.appendChild(this.fragment)
this.gallery
.querySelector('button.next')
.addEventListener('click', this.scrollToNextPage)
this.gallery
.querySelector('button.previous')
.addEventListener('click', this.scrollToPreviousPage)
this.calculateGalleryItemSize()
window.addEventListener('resize', this.calculateGalleryItemSize)
}
disconnectedCallback() {
window.removeEventListener('resize', this.calculateGalleryItemSize)
}
ingestChildren() {
this.fragment = document.createDocumentFragment()
Array.from(this.children).forEach((child) => {
this.fragment.appendChild(child)
})
}
calculateGalleryItemSize() {
const nodes = Array.from(this.galleryScroller.children)
const firstElement = nodes.find(
(node) => node.nodeType === Node.ELEMENT_NODE,
)
this.galleryItemSize = firstElement.clientWidth
}
scrollToPreviousPage() {
this.galleryScroller.scrollBy(-this.galleryItemSize, 0)
}
scrollToNextPage() {
this.galleryScroller.scrollBy(this.galleryItemSize, 0)
}
render() {
this.innerHTML = `
<style>
</style>
<div id="paginated-gallery" class="gallery">
<div class="gallery-scroller"></div>
<button class="previous" aria-label="Previous"></button>
<button class="next" aria-label="Next" ></button>
</div>
`
}
}
window.customElements.define('scroll-snap-carousel', ScrollSnapCarousel)
If you want to try it out, the example above is available on Codepen. Like the previous example, open your browser debugger to see how it's constructed, and you'll notice the lack of shadow root in the component tree.
A very active thread on GitHub discusses the concept of a Shadow DOM that supports a stylable root. Still, it's in the discussion phase, so it will be long before this manifests into something usable.
Consider a Framework
Consider adopting something like Lit or Stencil to build Web Components. These frameworks provide standard utilities for working with Web Components and handle everyday tasks such as change detection, server-side rendering, localization, etc. I've personally worked with Lit and find it helpful for preventing common mistakes and pitfalls. Additionally, they provide a series of best practices for authoring components, which I often refer to. Something Stencil provides which is unique is a polyfill for slot
for usage in non-Shadow DOM components, which may be attractive to some.
Here's an example of a basic Lit element with TypeScript support. It's similar to my previous examples and ultimately results in something similar through class extensions.
import {html, css, LitElement} from 'lit';
import {customElement, property} from 'lit/decorators.js';
@customElement('simple-greeting')
export class SimpleGreeting extends LitElement {
static styles = css`p { color: blue }`;
@property()
name = 'Somebody';
render() {
return html`<p>Hello, ${this.name}!</p>`;
}
}
The philosophy of these frameworks is similar to that of any other: they help you move faster. With these, you get all the benefits of using Web Components without writing lots of boilerplates every time you write a component.
General Tips
I've learned a lot after working with Web Components for a while. Here's a general list of things I've picked up.
- Be careful with inheritance in cases where a component doesn't define its own DOM tree and instead renders a pre-configured version of another. If you have components that follow this pattern, you run the risk of exploding your applications with additional DOM nodes, especially if you forward slots. Consider using a class extension instead, or reconsider if a new component is necessary.
- Document your components. Tooling is available to analyze JSDoc comments and produce artefacts from the output, such as README files. An excellent add-on for Storybook will take that output and automatically generate controls for your component API.
- Use a linter and write unit tests. Web Components have a habit of silencing errors, which can be frustrating to track down. The more tooling you add around them, the better your developer experience. Lit for example provides a linter that can catch common mistakes.
- Keep tabs on the industry. Web components are still evolving, and some things are constantly being discussed or considered. Get involved in the conversation!
Conclusion
Overall, I really enjoy working with Web Components. While some have given them a bad rap, the technology is progressing in a favourable way, and many teams are starting to seriously consider them. They are the backbone to the design system I work on and have been a great way to ensure consistency across our applications.
Related Reading
Want to learn more about Design Systems? Check out some of my other articles below.