One of the most desired goals of any UI framework or library is to help establish common patterns or conventions. These conventions make UI code easy to share and reason about. For a long time, every framework or library had its own implementation or version of a UI component. These components help code reusability if you stay in just the ecosystem of that tool. Quickly being locked into a specific eco system can become a problem if the given UI component/widget needs to be used in a different technology.

For example if I write an Angular library and want to use my Angular dropdown in my Vue application I can’t currently (yet). Wouldnt it be great if I could write a common standard component that would work in any browser with any JavaScript library or framework? This is the promise of Web Components.

Web Components are a collection of standardized low-level browser APIs to make it easier to create shared reusable UI components. In this tutorial, we will cover a few of these APIs by building our first web component. Our component will be a simple counter component that when clicked increments or decrements a value.

To make our counter component we will cover three different APIs, Custom Elements, Templates and the Shadow DOM API. First, let’s get started with the Custom Elements API.

Web Components - Custom Elements

The Custom Elements API allows us to define our own custom HTML elements and attach behavior to those elements. Using the Custom Elements API, we can also extend native HTML elements. First, let’s create a minimal custom element.



export class XCounter extends HTMLElement {
  constructor() {
    super();
  }

  connectedCallback() {
    this.innerHTML = `
      <p>Hello From Web Component</p>
    `;
  }
}

customElements.define('x-counter', XCounter);


Above we have the minimal amount of code to create our first custom element. To create a custom element we extend the HTMLElement class. In our class, we can define a template and any behavior we want. We are attaching our template to the elements innerHTML in a special lifecycle hook connectedCallback(). The connectedCallback() method is called after the constructor has been executed and the element has been inserted into the DOM.

After we have defined our Custom Element class, we need to register the element. customElements.define('x-counter', XCounter); When registering the element we pass two parameters. The class reference and the name of the element we will reference in our HTML. When naming our elements, we must have at least one dash in the name. Custom Elements require at least one dash to prevent naming collisions with existing HTML elements.

Now that we have created the basic structure of our Web Component let’s add a full template to create our counter component.

Template and Shadow DOM APIs



const template = document.createElement('template');
template.innerHTML = `
  <style>
    button, p {
      display: inline-block;
    }
  </style>
  <button aria-label="decrement">-</button>
    <p>0</p>
  <button aria-label="increment">+</button>
`;

export class XCounter extends HTMLElement {
  constructor() {
    super();
    this.root = this.attachShadow({ mode: 'open' });
    this.root.appendChild(template.content.cloneNode(true));
  }
}

customElements.define('x-counter', XCounter);


The Template and Shadow DOM APIs will allow us to create encapsulated and performant components. We first declare a new Template element. With document.createElement('template'); we can define an isolated native HTML template. Creating a template allows us to construct an HTML node tree without having to insert it into the DOM immediately. By using the template, we can create the template once and then reuse it every time an instance of our component is created.

We attach our newly created template via the Shadow DOM API instead of our previous example of innerHTML. To attach our template to our component via the Shadow DOM API we add the following in our constructor.



this.root = this.attachShadow({ mode: 'open' });
this.root.appendChild(template.content.cloneNode(true));


The Shadow DOM can be created in the constructor of our element eliminating the need of connectedCallback. The Shadow DOM creates an encapsulated/isolated section of DOM for our component. The Shadow DOM protects our HTML from being altered accidentally by global CSS or external JavaScript. For example, in our template above we have the following CSS:



button, p {
  display: inline-block;
}


With the styles being defined in our Shadow DOM template only the buttons and paragraph tags in our Web Component will be styled with inline-block. The styles will not leak out globally, and CSS styles globally will not override our template accidentally. Now that we have our template set up and created we need to add some click event handlers to our buttons.

Custom Elements - Properties

To communicate with Web Components we primarily pass data to it via public properties defined on the component. For our component we will create a public value property.



const template = document.createElement('template');
template.innerHTML = `
  <style>
    button, p {
      display: inline-block;
    }
  </style>
  <button aria-label="decrement">-</button>
    <p>0</p>
  <button aria-label="increment">+</button>
`;

export class XCounter extends HTMLElement {
  set value(value) {
    this._value = value;
    this.valueElement.innerText = this._value;
  }

  get value() {
    return this._value;
  }

  constructor() {
    super();
    this._value = 0;

    this.root = this.attachShadow({ mode: 'open' });
    this.root.appendChild(template.content.cloneNode(true));

    this.valueElement = this.root.querySelector('p');
    this.incrementButton = this.root.querySelectorAll('button')[1];
    this.decrementButton = this.root.querySelectorAll('button')[0];

    this.incrementButton
      .addEventListener('click', (e) => this.value++);

    this.decrementButton
      .addEventListener('click', (e) => this.value--);
  }
}

customElements.define('x-counter', XCounter);


On our component, we create a get and set for our value property. Using a getter and setter, we can trigger updates to our template. We have a private _value to retain the counter value. In our setter we update the number value with the following: this.valueElement.innerText = this._value;.

With our public value getter we can set the value of our counter dynamically. For example, we get a reference to our element and interact with it just like any other HTML element.



<x-counter></x-counter>




import 'counter.js';
const counter = document.querySelector('x-counter');
counter.value = 10;


As you can see we can query our custom element and set our custom properties just like any other HTML element. With our component we can pass data to it via input properties but what if we want out component to notify us when the user has changed the value of the counter? Next, we will look at custom events.

Custom Elements - Events

Just like any HTML element our custom element can emit custom events for us to listen to. In our use case, we want to know when the user has updated the value of our counter component. Let’s take a look at our updated component.



const template = document.createElement('template');
template.innerHTML = `
  <style>
    button, p {
      display: inline-block;
    }
  </style>
  <button aria-label="decrement">-</button>
    <p>0</p>
  <button aria-label="increment">+</button>
`;

export class XCounter extends HTMLElement {
  set value(value) {
    this._value = value;
    this.valueElement.innerText = this._value;

    // trigger our custom event 'valueChange'
    this.dispatchEvent(new CustomEvent('valueChange', { detail: this._value }));
  }

  get value() {
    return this._value;
  }

  constructor() {
    super();
    this._value = 0;

    this.root = this.attachShadow({ mode: 'open' });
    this.root.appendChild(template.content.cloneNode(true));

    this.valueElement = this.root.querySelector('p');
    this.incrementButton = this.root.querySelectorAll('button')[1];
    this.decrementButton = this.root.querySelectorAll('button')[0];

    this.incrementButton
      .addEventListener('click', (e) => this.value++);

    this.decrementButton
      .addEventListener('click', (e) => this.value--);
  }
}

customElements.define('x-counter', XCounter);


With our component, we add a custom event in the setter of the value property.



this.dispatchEvent(new CustomEvent('valueChange', { detail: this._value }));


We can dispatch a custom event. The custom event class takes two parameters. The first parameter is the name of the event; the second parameter is the data we would like to pass back. Conventionally it is common to pass back an object with a detail property containing the changed data. When our custom event emits, we will be able to listen to the event getting both the event value as well as details about the element that emitted the event. To listen to the event, we can create an event listener just like a standard HTML element.



import 'counter.js';
const counter = document.querySelector('x-counter');
counter.value = 10;
counter.addEventListener('valueChange', v => console.log(v));


In our code, we can listen to the custom valueChange event, and here we log the value.

Custom Event via Web Component

Custom Elements - Attributes

Sometimes it is also convenient to provide a way to pass information to a component via attributes instead of properties. For example, we may want to pass in a starting value to our counter.



<x-counter value="5"></x-counter>


To do this, we need to add some additional code to our component.



const template = document.createElement('template');
template.innerHTML = `
  <style>
    button, p {
      display: inline-block;
    }
  </style>
  <button aria-label="decrement">-</button>
    <p>0</p>
  <button aria-label="increment">+</button>
`;

export class XCounter extends HTMLElement {
  // Attributes we care about getting values from.
  static get observedAttributes() {
    return ['value'];
  }

  set value(value) {
    this._value = value;
    this.valueElement.innerText = this._value;
    this.dispatchEvent(new CustomEvent('valueChange', { detail: this._value }));
  }

  get value() {
    return this._value;
  }

  constructor() {
    super();
    this._value = 0;

    this.root = this.attachShadow({ mode: 'open' });
    this.root.appendChild(template.content.cloneNode(true));

    this.valueElement = this.root.querySelector('p');
    this.incrementButton = this.root.querySelectorAll('button')[1];
    this.decrementButton = this.root.querySelectorAll('button')[0];

    this.incrementButton
      .addEventListener('click', (e) => this.value++);

    this.decrementButton
      .addEventListener('click', (e) => this.value--);
  }

  // Lifecycle hook called when a observed attribute changes
  attributeChangedCallback(attrName, oldValue, newValue) {
    if (attrName === 'value') {
      this.value = parseInt(newValue, 10);
    }
  }
}

customElements.define('x-counter', XCounter);


On our component, we have to add two parts. First a list of attributes we want to be notified when changed. This is a required performance optimization for Web Components.



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


On our component, we also add a new lifecycle hook. The attributeChangedCallback() will be called whenever one of our observed attributes are updated via the HTML attribute.



attributeChangedCallback(attrName, oldValue, newValue) {
  if (attrName === 'value') {
    this.value = parseInt(newValue, 10);
  }
}


For Web Communication best practices its best to use custom properties over custom attributes. Properties are more flexible and can handle complex data types like Objects or Arrays. When using attributes, all values are treated as a String type as a limitation of HTML. Custom attributes are helpful but always start with properties and add attributes as needed. If you use a Web Component authoring tool such as StencilJS the tool automatically wires up attributes from properties and keeps them in sync.

Summary

With Web Components, we can create reusable Web UIs. Web components work in Chrome, Safari and soon to be shipped in Firefox. With polyfills we can also support Edge (working on implementing Web Component APIs now) and IE11. Most modern JavaScript frameworks also support Web Components as you can see full support at custom-elements-everywhere.com.

Want to learn more about Web Components in depth? Check out my early release book, Web Component Essentials!

Support this Blog View Code Demo