Web Components

Heads Up!

This article is several years old now, and much has happened since then, so please keep that in mind while reading it.

Do you remember the first time you Googled how to create an HTML page? Do you recall the moment of copying and pasting a <p> or an <a> from w3schools.org, pasting it into notepad, saving it as .html, and pulling it up in your favorite browser? That moment of seeing, for the first time, your digital creation coming to life! Sure, it was just "Hello world!" text, but YOU created that text and put it on the web for everyone to see. Today it seems nonsensical to publish something on the web without some CSS style and a little Javascript sparkle. Wouldn't it be great to reclaim that sense of wonder in our code and have the experience of grabbing some HTML elements to create stylish, interactive pages quickly? With the rise of a couple of new W3C specs this type of magical workflow is possible.

Web Components are a collection of web standards that allow developers to create reusable custom elements that are easily shareable and have styles/behaviors built into them using the basic building blocks of the web: HTML, CSS, and JavaScript. No framework needed! The specifications that make up web components are Custom Elements, Shadow DOM, Templates, and HTML Imports. Each spec is impressive on it's own, but with their powers combined they create a powerful web standard (kind of like captain planet) that has the potential to change how we develop on the web. While all four specs are useful, the only two specifications really needed to create our first magical custom element are Custom Elements and Shadow DOM. This post will dive into how to create a web component, how to use web component in the back office and frontend of a web application, and at the end I'll give some continued reading to show you who is using web components in production.

Browser Compatibility

I want to get this out of the way because most developers, myself included, won't focus on the rest of the content unless they know about browser compatibility. While the v1 specs for Custom Elements and Shadow DOM are fairly new they are agreed upon by most developers at the time of this post being written. Custom Elements has an ok adoption right now. It's supported by Chrome, desktop and mobile, and in Opera. It's currently in Safari Technology Preview, in development for Firefox, and being considered by Microsoft Edge. Shadow DOM has a slightly better adoption. It's supported by Chrome, desktop and mobile, Opera, and Safari, desktop and mobile, as of version 10. It's currently in development for Firefox and under consideration by Microsoft Edge.

The good news is that there are a few polyfills (custom elements, ShadyDOM, ShadyCSS) that have been created to fill in the gaps for browsers that do not natively support Custom Elements and/or Shadow DOM. There are a couple of very small quirks with the polyfills and while it took a day or two for me to understand what those were by talking to talented people on twitter, whom I'll link below, it's not so difficult to work with the couple of quirks I found. I'll be detailing those quirks in the post to help others on the web who want to use the polyfills to begin creating web components.

I'd like to note that the code I'm showing below is using ES2015 and for the best results with browser compatibility I suggest using Babel to transpile the code to ES5 either through your workflow process using something like Gulp or by using the online transpiler.

Custom Elements

To show the power of web components we'll be creating a special input element that has certain styles and behaviors built in. The first thing we need to create a web component is having our own uniquely named custom element. Using JavaScript we can register an HTML element to the DOM so it can be easily manipulated with CSS and JavaScript. Because it's registered in the DOM and recognized as an HTML Element, our custom element can work with any frontend framework (like jQuery, AngularJS, React, etc.) or, even better, with no framework at all!

We'll start by using an ES2015 class to register the new element <aris-input>:

class arisInput extends HTMLElement {
  	constructor() {
		// Always call super() first
		super();

		//This code is called when the element is created
		//Useful for adding event listeners
	}

	connectedCallback() {
		//This code is called when each time the element is put into the DOM.
		//Useful for grabbing data from the server through fetch
	}

	disconnectedCallback() {
		//This code is called when each time the element is removed from the DOM.
		//Useful for cleaning up your code
	}

	attributeChangedCallback(name, oldValue, newValue) {
		//This code is called when an attribute of the element is added,
		//removed, updated, or replaced.
	}
}

window.customElements.define('aris-input', arisInput);

Now that we have a basic element we can begin giving it super powers. An essential part of HTML elements are it's attributes. We can create attributes for our custom element by using a getter and a setter. Let's give our <aris-input> element a disabled attribute (it is an input after all) and a placeholder attribute:

class arisInput extends HTMLElement {
	static get observedAttributes() {
    	return ['disabled', 'placeholder'];
  	}

	// A getter/setter for a disabled property.
  	get disabled() {
    	return this.hasAttribute('disabled');
  	}
  	set disabled(val) {
		if (val) {
      		this.setAttribute('disabled', '');
    	} 
    	else {
      		this.removeAttribute('disabled');
    	}
  	}

  	// A getter/setter for a placeholder property.
  	static get placeholder() {
    	return this.hasAttribute('placeholder');
  	}
  	set placeholder(val) {
		if (val) {
      		this.setAttribute('placeholder', '');
    	} 
    	else {
      		this.removeAttribute('placeholder');
    	}
  	}

  	constructor() {
		super();
	}

	connectedCallback() {
	}

	attributeChangedCallback(name, oldValue, newValue) {
	}
}

window.customElements.define('aris-input', arisInput);

We want to make sure that when our element has a disabled attribute that we update the tabindex and aria-disabled attribute on the element so that keyboard and screen readers can behave appropriately:

class arisInput extends HTMLElement {
	static get observedAttributes() {
    	return ['disabled', 'placeholder'];
  	}

	// A getter/setter for a disabled property.
  	get disabled() {
    	return this.hasAttribute('disabled');
  	}
  	set disabled(val) {
		if (val) {
      		this.setAttribute('disabled', '');
    	} 
    	else {
      		this.removeAttribute('disabled');
    	}
  	}

  	// A getter/setter for a placeholder property.
  	static get placeholder() {
    	return this.hasAttribute('placeholder');
  	}
  	set placeholder(val) {
		  if (val) {
      		this.setAttribute('placeholder', '');
      } 
    	else {
      		this.removeAttribute('placeholder');
    	}
  	}

    constructor() {
  		  super();

        this.addEventListener('click', e => {
              if (this.disabled) {
                return;
              }
        });

        this.addEventListener('touchstart', e => {
              if (this.disabled) {
                return;
              }
        });
  	}

  	connectedCallback() {
  	}

  	attributeChangedCallback(name, oldValue, newValue) {
        if (this.hasAttribute("disabled")) {
            this.setAttribute('tabindex', '-1');
            this.setAttribute('aria-disabled', 'true');
        } 
        else {
            this.setAttribute('tabindex', '0');
            this.setAttribute('aria-disabled', 'false');
        }
  	}
}

window.customElements.define('aris-input', arisInput);

Shadow DOM

We have an HTML element called <aris-input> and we can add a placeholder and disabled attribute, but we don't have any basic content or styles baked into the element. Whether you realize it or not, spec HTML elements have styles baked inside. A <p> has padding on the top and bottom by default. A <ul> has padding left by default. We are essentially telling the browser what the basic properties of our custom element by using Shadow DOM.

Shadow DOM allows for isolated DOM meaning the DOM inside our web component won't be accessible via document.querySelector(). This allows for scoped CSS. Scoped CSS only applies to the DOM inside the web component. So if one web component has a class of "btn" in it, and another web component has a class of "btn", the two classes won't be competing. They only apply to the Shadow DOM of their respective web components.

In order to work with the polyfill, for browser support, we'll be going to use a function called makeTemplate. This function should go before our code for the web components so that the components can call it.

var makeTemplate = function (strings, ...substs) {
    let html = '';
    for (let i = 0; i < substs.length; i++) {
        html += strings[i];
        html += substs[i];
    }
    html += strings[strings.length - 1];
    let template = document.createElement('template');
    template.innerHTML = html;
    return template;
}

Now let's set a static getter in our web component that will make the template for us, which will include all of the HTML and CSS we want to put in the Shadow DOM.

class arisInput extends HTMLElement {
    static get template() {
      if (!this._template) {
        this._template = makeTemplate`
          <!-- inject-style src="./processing/aris-input/aris-input.css" -->
          <div id="arisInput" class="group">
            <slot></slot>
            <span class="highlight"></span>
            <span class="bar"></span>
            <label></label>
          </div>
        `;
      }
      return this._template;
    } 

	  static get observedAttributes() {
    	return ['disabled', 'placeholder'];
  	}

	  // A getter/setter for a disabled property.
  	get disabled() {
    	return this.hasAttribute('disabled');
  	}
  	set disabled(val) {
		if (val) {
      		this.setAttribute('disabled', '');
    	} 
    	else {
      		this.removeAttribute('disabled');
    	}
  	}

  	// A getter/setter for a placeholder property.
  	static get placeholder() {
    	return this.hasAttribute('placeholder');
  	}
  	set placeholder(val) {
		  if (val) {
      		this.setAttribute('placeholder', '');
      } 
    	else {
      		this.removeAttribute('placeholder');
    	}
  	}

  	constructor() {
		super();
	}

	connectedCallback() {
	}

	attributeChangedCallback(name, oldValue, newValue) {
      if (this.hasAttribute("disabled")) {
          this.setAttribute('tabindex', '-1');
          this.setAttribute('aria-disabled', 'true');
      } 
      else {
          this.setAttribute('tabindex', '0');
          this.setAttribute('aria-disabled', 'false');
      }
	}
}

window.customElements.define('aris-input', arisInput);

You may notice the element <slot> inside of our template. This element will take all the HTML inside of <aris-input> in your .html file and insert it into the Shadow DOM. We'll use this later to determine what type of input we want our <aris-input> element to display. Next let's create the Shadow DOM in our connectedCallback() and add a little more magic:

class arisInput extends HTMLElement {
    static get template() {
      if (!this._template) {
        this._template = makeTemplate`
          <!-- inject-style src="./processing/aris-input/aris-input.css" -->
          <div id="arisInput" class="group">
            <slot></slot>
            <span class="highlight"></span>
            <span class="bar"></span>
            <label></label>
          </div>
        `;
      }
      return this._template;
    } 

	  static get observedAttributes() {
    	return ['disabled', 'placeholder'];
  	}

	  // A getter/setter for a disabled property.
  	get disabled() {
    	return this.hasAttribute('disabled');
  	}
  	set disabled(val) {
		if (val) {
      		this.setAttribute('disabled', '');
    	} 
    	else {
      		this.removeAttribute('disabled');
    	}
  	}

  	// A getter/setter for a placeholder property.
  	static get placeholder() {
    	return this.hasAttribute('placeholder');
  	}
  	set placeholder(val) {
		  if (val) {
      		this.setAttribute('placeholder', '');
      } 
    	else {
      		this.removeAttribute('placeholder');
    	}
  	}

  	constructor() {
		super();
	}

	connectedCallback() {
    let shadowRoot = this.attachShadow({mode: 'open'});
    shadowRoot.appendChild(document.importNode(arisInput.template.content, true));
    
    // Shim styles, CSS custom props, etc. if native Shadow DOM isn't available.
    if (!!HTMLElement.prototype.attachShadow) {
      ShadyCSS.applyStyle(this);
    }

    let input = this.querySelector('input');
    let label = shadowRoot.querySelector('label');
    let rootThis = this;

    input.addEventListener('keyup', function(event) {
      rootThis.value = event.target.value;
      if (event.target.validity.valid) {
        input.classList.add("valid");
        input.classList.remove("invalid");
      } else {
        input.classList.add("invalid");
        input.classList.remove("valid");
      }
    }, false);

    if (this.hasAttribute("placeholder")) {
        label.innerHTML = this.getAttribute("placeholder");
    }
	}

	attributeChangedCallback(name, oldValue, newValue) {
      if (this.hasAttribute("disabled")) {
          this.setAttribute('tabindex', '-1');
          this.setAttribute('aria-disabled', 'true');
      } 
      else {
          this.setAttribute('tabindex', '0');
          this.setAttribute('aria-disabled', 'false');
      }
	}
}

window.customElements.define('aris-input', arisInput);

attachShadow creates the Shadow DOM for the element and appendChild() clones the template by calling arisInput.template.content. After that we check to see if Shadow DOM v1 is supported by the browser natively and if it isn't we use the ShadyCSS polyfill to apply the styles in our web component. Last we have some JavaScript to help us know if the content in the input is valid or invalid so we can give the user some feedback using CSS and we take the text inside the "placeholder" attribute and insert it into the <label> of the Shadow DOM.

Finally, let's add ShadyCSS.prepareTemplate() right before we call customElements.define() so that our CSS is scoped properly:

class arisInput extends HTMLElement {
    static get template() {
      if (!this._template) {
        this._template = makeTemplate`
          <!-- inject-style src="./processing/aris-input/aris-input.css" -->
          <div id="arisInput" class="group">
            <slot></slot>
            <span class="highlight"></span>
            <span class="bar"></span>
            <label></label>
          </div>
        `;
      }
      return this._template;
    } 

	  static get observedAttributes() {
    	return ['disabled', 'placeholder'];
  	}

	  // A getter/setter for a disabled property.
  	get disabled() {
    	return this.hasAttribute('disabled');
  	}
  	set disabled(val) {
		if (val) {
      		this.setAttribute('disabled', '');
    	} 
    	else {
      		this.removeAttribute('disabled');
    	}
  	}

  	// A getter/setter for a placeholder property.
  	static get placeholder() {
    	return this.hasAttribute('placeholder');
  	}
  	set placeholder(val) {
		  if (val) {
      		this.setAttribute('placeholder', '');
      } 
    	else {
      		this.removeAttribute('placeholder');
    	}
  	}

  	constructor() {
		super();
	}

	connectedCallback() {
    let shadowRoot = this.attachShadow({mode: 'open'});
    shadowRoot.appendChild(document.importNode(arisInput.template.content, true));
    
    // Shim styles, CSS custom props, etc. if native Shadow DOM isn't available.
    if (!!HTMLElement.prototype.attachShadow) {
      ShadyCSS.applyStyle(this);
    }

    let input = this.querySelector('input');
    let label = shadowRoot.querySelector('label');
    let rootThis = this;

    input.addEventListener('keyup', function(event) {
      rootThis.value = event.target.value;
      if (event.target.validity.valid) {
        input.classList.add("valid");
        input.classList.remove("invalid");
      } else {
        input.classList.add("invalid");
        input.classList.remove("valid");
      }
    }, false);

    if (this.hasAttribute("placeholder")) {
        label.innerHTML = this.getAttribute("placeholder");
    }
	}

	attributeChangedCallback(name, oldValue, newValue) {
      if (this.hasAttribute("disabled")) {
          this.setAttribute('tabindex', '-1');
          this.setAttribute('aria-disabled', 'true');
      } 
      else {
          this.setAttribute('tabindex', '0');
          this.setAttribute('aria-disabled', 'false');
      }
	}
}

ShadyCSS.prepareTemplate(arisInput.template, 'aris-input');
window.customElements.define('aris-input', arisInput);

There we have a functional web component!

Web Components in Production

You can preview this web component, and another web component I created, <aris-header>, on Github. In the Github repo you can find all the code used to create the preview. You can use the custom elements you create in the back-office and on the frontend of projects simply by including the JavaScript file with the code for your custom element. We are currently testing the use of web components in custom list views for our back-office and on the frontend of our projects. Initial results look very promising.

Currently websites like Github, EA Games, Google, and other companies have started using small components in their websites. It's easy to dip your toes into the world of web components since it's simply HTML, CSS, and JavaScript and they work with any framework you might be currently using! In researching further you might discover that there are frameworks to help you create web components. In my opinion this seems counterproductive to what web components are supposed to bring to the table.

There's a great community of developers involved in web components online. To learn more about web components you could follow Eric Bidelman from Google who has great articles on custom elements and Shadow DOM. I'd also suggest checking out and following webcomponents.org, where you can see up to date browser support charts, blog about all 4 web component specs, and discover web components that others have created.

The future of web development is very bright! Being able to quickly share web components with others using Github is a great way to quickly bootstrap a project. What are your thoughts? Do you like the idea of shareable bite sized components?

Matt Shull

Matt is on Twitter as