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.

There's a subtle reference here.

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.

index.html
<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.

Several companies have adopted Web Components already, most notably YouTube, GitHub, Adobe, and Alaska Airlines. All of these companies have been around for a long time and have a lot of applications they maintain.

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.

style.css
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.

index.html
<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.

daily-greeting.js
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.

scroll-snap-carousel.js
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.

scroll-snap-carousel.js
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.

index.html
<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.

scroll-snap-carousel-no-shadowdom.js
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.

simple-greeting.ts
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.

You'll make mistakes, especially if it's your first time working with Web Components. While they are similar to working with other frameworks, they have their unique quirks that aren't always obvious at first.

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.

Want to learn more about Design Systems? Check out some of my other articles below.