Simplifying UI with Web Components

-

In the ever-evolving landscape of web development, the quest for more efficient, scalable, and maintainable front-end solutions is perpetual. In this blog post, we dive into simplifying UI with Web Components.

Unlike native HTML elements, Web Components are not provided by the browser by default. Instead, they are crafted by developers, empowering them to extend HTML itself, defining new elements that behave as if they were native to the web platform.

This approach not only leads to more robust and less error-prone applications but also significantly streamlines the development process. The best thing of all: you can use them in all frameworks without a big learning curve, or without frameworks at all!

Section 1: the basics of using Web Components

Components in frameworks

If you have used a framework you may have noticed that your components usually come with a lot of imports or framework-specific file extensions. For example, two major frameworks:

// Angular
import { Component } from '@angular/core';
@Component({
  selector: 'app-component-overview',
  templateUrl: './component-overview.component.html',
})

// React
class Welcome extends React.Component {
  render() {
    return <h1>Hello, {this.props.name}</h1>;
  }
}

These imports or file-extensions mean a lot of (potentially big) dependencies and framework specific knowledge. Web Components are native to the browser and as a result they come without complex patterns. This brings upsides and downsides. For sharing components, it brings mostly downsides to use big frameworks if the other project don’t use the same framework. That’s where Web Components jump in.

Upsides of native Web Components:

  • Every framework supports them without any complexity because they are native
  • They are private using Shadow Dom, they don’t mess up the rest of your page
  • You don’t need a framework to create them, although many support the creation
  • They easily integrate into Storybook

Downsides of native Web Components:

  • No out-of-the-box solutions for routing
  • No data management solutions built in
  • IDE’s don’t support parameter or css styles in autocomplete features

Web Components

Web Components are a browser-native way to write components that can be used in any framework but even without any framework at all. Let me show you an example of a Web Component that uses no frameworks at all:

class HelloWorld extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <span class="hello">Hello World!</span>
    `;
  }
}
customElements.define('hello-world', HelloWorld);

You can write this in a JavaScript file and simply write your HTML like this:

<body>
    <hello-world></hello-world>
    <script src=”hello-world.js”></script>
</body>

Without any frameworks or magic, you now have a native look-a-like HTML tag!

Shadow DOM

Shadow DOM allows developers to encapsulate a piece of the webpage, so its styles and scripts do not interfere with the rest of the page, and vice versa. Think of it like a small, self-contained bubble within your webpage. Elements inside this bubble can have their own HTML, CSS, and JavaScript, which are separate from the rest of the page.

In the following example, let’s assume I used the HelloWorld example written in the previous section. I imported the scripts the right way. The HTML looks like this:

<hello-world></hello-world>
<span class="hi">Hello World!</span>

Both elements render the exact same If I query a native element, or any Web Component without a shadow DOM, you get to see the structure like this:

console.log(document.querySelector('.hi'));
// <span class="hi">Hello World!</span>

We now query the Web Component with an open Shadow DOM attached to it, and the result might be different from what you expect to see:

console.log(document.querySelector('hello-world'));
// You might expect:
// <hello-world><span class="hello">
//   Hello World!
// </span></hello-world>
// The actual result:
// <hello-world></hello-world>

Shadow DOM shields the internal structure from the script, which means the component is private. As a result, the browser allows you to interact with the element internals through the Shadow DOM. By default your component is left alone and you don’t have to be afraid of leaking style issues!

Section 2: useful concepts for web components

Rendering HTML and CSS

HTML is the foundation of everything on the web. If you want to show text on the web you use a <p> tag. If you want to draw, you use <canvas>. If you want your awesome-stuff, you use <awesome-stuff>.

I think you can guess what the following element does.

class AwesomeStuff extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
        this.render();
    }

    css() {
        return `
            img {
                width: 150px;
            }
        `;
    }

    render() {
        this.shadowRoot.innerHTML = `
            <img src="https://cataas.com/cat" />

            <style>${this.css()}</style>
        `;
    }
}
customElements.define('awesome-stuff', AwesomeStuff);

Using Attributes to insert information

Web Components are usually stand-alone components that allow or desire no direct mutations and interactions with the outside world. You can, however, define your own so-to-say ‘api’ by reading Attributes and emitting Events.

Attributes can be used to put information into a Web Component:

<awesome-stuff data-name="Randy"></awesome-stuff>

The ‘data-name’ attribute holds the value ‘Randy’ and can be read from the custom-element like this:

class AwesomeStuff extends HTMLElement {
    static observedAttributes = ['name'];

    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
        this.name = 'World'; // Default value for the name
        this.render();
    }

    // This is a native method that gets called by the browser
    attributeChangedCallback(attrName, oldValue, newValue) {
        if (attrName === 'name') {
            this.name = newValue; // Update the name property
            this.render();
        }
    }

    render() {
        this.shadowRoot.innerHTML = `<h1>Hello ${this.name}!</h1>`;
    }
}
customElements.define('awesome-stuff', AwesomeStuff);

You can see I used ‘data-name’ and not simply ‘name’. They both work, but some frameworks use strict HTML parsing and ‘data-name’ is a supported property while oficially ‘name’ is not.
https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes

Using events to receive information

Web Components can share information by emitting events. The browser can be told to observe these events and trigger functions. The elements inside of the Web Component will also trigger events, but these won’t be accessible outside of the Shadow DOM.

In the next example you see a Web Component which holds a button, triggering logic and emitting custom events.

class AwesomeStuff extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: "open" });
    this.render();
  }

  connectedCallback() {
    const helloButton = this.shadowRoot.querySelector("#hello-button");

    helloButton.addEventListener("click", () =>
      this.dispatchEvent(new CustomEvent("custom-event-hello", { detail: "Data from hello button" }))
    );
  }

  render() {
    this.shadowRoot.innerHTML = `
      <button id="hello-button">Hello!</button>
    `;
  }
}
customElements.define('awesome-stuff', AwesomeStuff);

Listening to these events requires the use of a little JavaScript:

function setupEventListeners() {
  const awesomeStuffElement = document.querySelector('awesome-stuff');

  awesomeStuffElement.addEventListener('custom-event-hello', (e) => {
    alert('Custom event triggered:', e.detail);
  });
}

Slots

Web Components are shielded from the outside, and the outside is shielded from the Web Components. But that does not mean they cannot coöperate! We’ve seen Attributes and Events, which allow data flowing in and out of the components. Next to those, we can use Slots. It does not share data or expose anything but it allows the outside to rent some space within the Web Component. Let’s go back to the basics:

class AwesomeStuff extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `<span class="hello">Hello World!</span>`;
  }
}
customElements.define('awesome-stuff', AwesomeStuff);

<awesome-stuff>I am not yet rendered</awesome-stuff>

This still renders ‘Hello World!’. We need to add a <slot></slot> to render the text inside of the <awesome-stuff> tags like this:

class AwesomeStuff extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: "open" });
    this.shadowRoot.innerHTML = `<slot><p>Default content.</p></slot>`;
  }
}
customElements.define('awesome-stuff', AwesomeStuff);

<awesome-stuff>I am now inside of awesome-stuff</awesome-stuff>

The text ‘Default content.’ is only rendered if nothing, not even whitespace, is between the <awesome-stuff> tags.

Section 3: Creating a complex web component together

Let’s create some 3D dice. These are so complex, you might want to re-use it instead of rewriting it later in other places or other projects. Good thing we use Web Components to create a Custom Element.

The HTML is simple:

<div id="die">
    <div class="face face-1">1</div>
    <div class="face face-2">2</div>
    <div class="face face-3">3</div>
    <div class="face face-4">4</div>
    <div class="face face-5">5</div>
    <div class="face face-6">6</div>
</div>

The CSS will quickly become complex, so lets do it in steps. First, let the die rotate in an animation so we can see the 3D changes. Then we make sure the fonts are set up and all faces are the same size. We also give the faces an orange background.

#die {
    /* note these CSS variables and calculations are native CSS! */
    --die-size: 50px;
    --face-offset: calc(var(--die-size) / 2);
    position: relative;
    width: var(--die-size);
    aspect-ratio: 1 / 1;
    font-size: var(--face-offset);
    transform-style: preserve-3d;
    animation: rotate 5s infinite linear;
}

.face {
    position: absolute;
    width: var(--die-size);
    aspect-ratio: 1 / 1;
    background: orange;
    text-align: center;
    font-size: var(--die-size);
    line-height: var(--die-size);
}

@keyframes rotate {
    0% { transform: rotateX(0deg) rotateY(0deg); }
    100% { transform: rotateX(360deg) rotateY(360deg); }
}

We now have 6 orange squares in the same absolute position (so you only see one). Now let’s add some magic that makes them a 3D cube!

.face-1 {
    transform: rotateY(0deg) translateZ(var(--face-offset));
}
.face-2 {
    transform: rotateX(270deg) translateZ(var(--face-offset));
}
.face-3 {
    transform: rotateY(90deg) translateZ(var(--face-offset));
}
.face-4 {
    transform: rotateY(270deg) translateZ(var(--face-offset));
}
.face-5 {
    transform: rotateX(90deg) translateZ(var(--face-offset));
}
.face-6 {
    transform: rotateY(180deg) translateZ(var(--face-offset));
}

Now let’s add some logic to let the user decide which side the die should roll to.

  static get observedAttributes() {
    return ["value"];
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === "value") {
      this.updateFace(newValue);
    }
  }

  updateFace(value) {
    const die = this.shadowRoot.querySelector("#die");
    if (value) {
      die.style.animation = "none";
      switch (value) {
        case "1":
          return die.style.transform = "rotateX(0deg) rotateY(0deg)";
        case "2":
          return die.style.transform = "rotateX(90deg) rotateY(0deg)";
        case "3":
          return die.style.transform = "rotateX(0deg) rotateY(-90deg)";
        case "4":
          return die.style.transform = "rotateX(0deg) rotateY(90deg)";
        case "5":
          return die.style.transform = "rotateX(-90deg) rotateY(0deg)";
        case "6":
          return die.style.transform = "rotateX(0deg) rotateY(-180deg)";
      }
    } else {
      die.style.animation = "rotate 5s infinite linear";
    }
  }

The resulting Web Component

class AwesomeStuff extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: "open" });
    this.render();
  }

  static get observedAttributes() {
    return ["value"];
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === "value") {
      this.updateFace(newValue);
    }
  }

  updateFace(value) {
    const die = this.shadowRoot.querySelector("#die");
    if (value) {
      switch (value) {
        case "1":
          return die.style.transform = "rotateX(0deg) rotateY(0deg)";
        case "2":
          return die.style.transform = "rotateX(90deg) rotateY(0deg)";
        case "3":
          return die.style.transform = "rotateX(0deg) rotateY(-90deg)";
        case "4":
          return die.style.transform = "rotateX(0deg) rotateY(90deg)";
        case "5":
          return die.style.transform = "rotateX(-90deg) rotateY(0deg)";
        case "6":
          return die.style.transform = "rotateX(0deg) rotateY(-180deg)";
      }
    }
  }

  render() {
    this.shadowRoot.innerHTML = `
    <div id="die">
      <div class="face face-1">1</div>
      <div class="face face-2">2</div>
      <div class="face face-3">3</div>
      <div class="face face-4">4</div>
      <div class="face face-5">5</div>
      <div class="face face-6">6</div>
    </div>
    <style>${this.css()}</style>
  `;
  }

  css() {
    return `
      #die {
          --die-size: 50px;
          --face-offset: calc(var(--die-size) / 2);
          position: relative;
          width: var(--die-size);
          aspect-ratio: 1 / 1;
          font-size: var(--face-offset);
          transform-style: preserve-3d;
          transition: transform 1s ease-out;
      }

      .face {
          position: absolute;
          width: var(--die-size);
          aspect-ratio: 1 / 1;
          background: orange;
          text-align: center;
          font-size: var(--die-size);
          line-height: var(--die-size);
      }

      .face-1 {
          transform: rotateY(0deg) translateZ(var(--face-offset));
      }
      .face-2 {
          transform: rotateX(270deg) translateZ(var(--face-offset));
      }
      .face-3 {
          transform: rotateY(90deg) translateZ(var(--face-offset));
      }
      .face-4 {
          transform: rotateY(270deg) translateZ(var(--face-offset));
      }
      .face-5 {
          transform: rotateX(90deg) translateZ(var(--face-offset));
      }
      .face-6 {
          transform: rotateY(180deg) translateZ(var(--face-offset));
      }
    `;
  }
}
customElements.define("awesome-stuff", AwesomeStuff);

This button generates a random value between 1 and 6, and applies it to the component:

<awesome-stuff id="awesome"></awesome-stuff>

<button onclick="document.getElementById('awesome').setAttribute('value', Math.ceil(Math.random() * 6))">roll</button>

So why are Web Components not mainstream yet?

I know some big companies use them. A big bank in The Netherlands uses almost nothing else for their entire front-end, including the mobile app. They are production ready, awesome and well structured. The problem I see is that the big frameworks don’t have an easy way to build them and don’t easily steer you towards Web Components (probably because they want you to use their framework 😉). Like I mentioned, they are not a replacement of any framework because of missing features, so instead of using them natively a lot of projects fall back to frameworks.

Conclusion on Web Components

Web Components are awesome! You can further optimize this dice component and re-use it everywhere, or build something completely different and still use it everywhere! Web Components are great for your low-level components like custom buttons, form elements and other elements that have specific company-wide layouts. They are also great for isolated components such as wizards that guide users through something, complete forms that appear many times in your web application or even your entire website with the right tooling!

However,  I do recommend using a framework if you are going to create an entire web application with Web Components, such as Lit, to make sure you have an easier way to do lifecycle hooks, data management and testability. Lit is super small and very close to the native specification so everything I just explained, will work (better) using Lit and the components are equally well distributable if you do have Lit as a dependency. Check out this next codepen for a nice example of two Web Components, the dice with optional 4, 6, 8, 10, 12 and 20 sides and a dicetray, which are a little more complex.

Further recommondations:

I built (better) dice and a dicetray, find it here. Looking for the NPM release of the dice component? Find that here. Want to share your thoughts? Get in touch with me via LinkedIn.