Reusable UI - Web Components, Shadow Root and Templates

August 2020

Reusable code makes for easier code testing and maintenance. Write once, test thoroughly, use everywhere.

How would you handle creating reusable UI components for front-end development?

  • You could use a development framework like React. That presents a problem of portability of already developed components between different applications written in a mix of languages.
  • You could render a component inside JavaScript and import that to the page. But that's clunky, especially on large and complex elements as CSS, HTML, and JavaScript are all mixed up together. It also forces the use of JavaScript, where there is no need for one, resulting in an overengineered solution.
  • You could go native and use web components.

From HTML Imports to Web Components

Officially deprecated HTML imports looked promising. Supported by Chrome, this experimental standard had assisted developers in enforcing logic separation. HTML templates could be kept in separate files and imported to the pages that required the use of the component.

Few problems emerged: JavaScript variables name collision (since there wasn't a way to declare a namespace), having to wait for the script to load before HTML was fully functional, and, more importantly, lack of support from browser vendors. In the end, the concept was experimental, and Chrome had deprecated HTML imports at the beginning of 2020. The underlying technology of Shadow DOM V0 and Custom Elements V0 was replaced by wildly adopted Shadow DOM V1 and Custom Elements V1.

Along with HTML Templates, Shadow DOM and Custom Elements are three underlying technologies of Web Components.

Shadow DOM

Think about your stylesheets. If you assign specific properties to the <a> or <div> tag, it will apply to every single element unless overwritten.

But sometimes that behavior is not desirable.

If you enable "Show Shadow dom" option in Chrome and look at the <select> element or date picker shown for <input type="date" > fields, it becomes evident that other elements are used to compose them.

And we wouldn't want the outside CSS rules to apply to these elements, altering the behavior or look. We also may not want to allow properties to be accessible via scripting.

Shadow DOM provides scope protection, encapsulating CSS and scripting for the component, and not allowing them to be altered from the outside. It's called "shadow" because encapsulated DOM is rendered separately from the rest of the document.

Shadow DOM is manipulated via JavaScript API by accessing ShadowRoot interface.

To attach Shadow DOM, call

this.attachShadow({mode:'open'})

Mode 'open' means that outside JavaScripts can access the content of the Shadow root. Setting mode value to close would create a restricted component and won't return a reference when this.shadowRoot is called.

To set ShadowRoot DOM, use

this.shadowRoot.innerHTML='<h2>Shadow Root Header</h>'

Custom Elements

Custom elements are used to create new DOM elements.

Custom Elements must follow a few rules:

  1. They cannot be void elements (like <br> and <meta>), meaning they should have the closing tag.
  2. Their name should be all lower case with dashes. No spaces allowed.
  3. They can only be registered once.

Custom Elements can be customized (i.e., inheriting from HTML elements like div, span, etc.) or autonomous.

To create an autonomous Custom Element, write a class inheriting from HTMLElement ( see the spec here) using ES 2015 specification.

VoteComponent.js

class VoteComponent extends HTMLElement{
      constructor(){
         super();
         const shadowRoot=this.attachShadow({mode: 'open'});
                    const button = document.createElement("button");    
                    button.innerHTML = "Click to vote";                   
                    
                    let style = document.createElement('style');
                    style.textContent=`button{
                                        background: yellow;
                                        width:100px;
                                        height:50px;
                                    }`
                    shadowRoot.appendChild(button);
                }
}

customElements.define('vote-component', VoteComponent);

The last line registers custom control:

customElements.define('vote-component',VoteComponent);

where customElements is an instance of CustomElementRegistry interface. This interface can also be used to retrieve the constructor of previously defined Custom Element

customElements.get('vote-component');

Once Custom Element is created, include it in the HTML page via script import

index.html

[****]
<body>
    <vote-component>
   </vote-component>
 
    <script  src="./components/voteComponent.js"></script> 
</body>
[***]

HTML Templates and Slots

Instead of using appendChild in the Custom Component class, it's possible to write HTML markup directly by using <template> tag and then attach the cloned template to the Shadow Root.

<slot> element is then used to explicitly allow modifications of elements inside the Shadow Root when called by name.

index.html

    <body> 
        <template id="vote-message-template">
            <style>
                div{
                    padding: 5px;
                    margin-bottom:10px;
                    background-color: lightslategray;
                    border-style: solid;
                    border-color: maroon;
                    max-width: 350px;
                }
            </style>
            <div> 
                <hr> 
                    <h2>
                        <slot name="vote-message">Default value</slot>
                      </h2>
                    <hr> 
            </div>
        </template>
        
       <h1>Welcome to the voting booth!</h1>
          <vote-message>
              <span slot="vote-message">Vote for cats, they are cute!</span>
          </vote-message>

        <vote-message>
            <span slot="vote-message">Vote for dogs, they are best friends!</span>
       </vote-message>
      <vote-message>
      </vote-message>
       <script src="./components/voteMessage.js"></script>   
  </body>
 [***]

VoteMessage.js

class VoteMessage extends HTMLElement{
   
    constructor() {
      super();
      const template = document.getElementById('vote-message-template').content;
      this.attachShadow({mode: 'open'}).appendChild(template.cloneNode(true));
  
    }
  }
  customElements.define('vote-message', VoteMessage);

If <slot> value is not specified when the template is used, the default value is displayed.

Events and data passing between the web components

<slot> can be used to pass user markup into the Shadow Root, which allows for a certain degree of customization.

How would you pass data in and out of the web component?

Just as always, there are many ways of accomplishing this.

Passing data from the web component

Use callback

VoteComponent.js

shadowRoot.querySelector('button').addEventListener('click', () => {
                        alert ("Button clicked");
                        this.dispatchEvent(
                          new CustomEvent('onClick', {
                            detail: {name:'Cat'},
                          })
                        );
                      });
                    }

Dispatch event when the button is clicked. A payload is included in a detail object.

index.html

<script >
  
  document
    .querySelector('vote-component')
    .addEventListener('onClick', value => {
       let detailValue=JSON.stringify(value.detail);
       alert (`Thank you for voting for ${detailValue.name}`);
    })
 
</script>

Add event listener to catch the event and use payload data.

Passing data to web component

Attributes are one way of passing data in the web component.

index.html

<vote-message checkboxvalue="Cat">
    <span slot="vote-message">Vote for cats, they are cute!</span>
</vote-message>

Here checkboxvalue is an attribute passed in the vote-message component.

VoteMessage.js

class VoteMessage extends HTMLElement{
 
    constructor() {
      super();
      [***]
      this.shadowRoot.getElementById('voteanimal').addEventListener('change', () => {
        alert (this.shadowRoot.getElementById('voteanimal').value);
        
      });
    }
    
    connectedCallback(){
             this.shadowRoot
                .getElementById('voteanimal')
                .value=this.getAttribute("checkboxvalue");
    }
    

The passed attribute value is used to set the value for the checkbox. TconnectedCallback() event and not a constructor() - because when the constructor is fired, the element is created but not attached to DOM yet so attributes cannot be set.

CORS errors

When testing locally, merely opening the file in the browser may cause CORs error like the one below. To address, serve the file via the HTTP server instead.

One of the easiest options to do that is to use python3 http.server:

cd demo_folder
python3 -m http.server 8181

This will serve the file on port 8181

Browser Support

As of right now, most modern browsers will support the technology behind web components

Web Component Toolchain: Stencil

While it's essential to understand the technology fundamentals, frameworks and toolchains do increase productivity.

Stencil is built on open web standards and enhances the development workflow. While not fully mature yet, the intention is to be able to build components that can easily integrate into various frameworks like React, Vue, and Ember and in static site generation.

So if Gatsby/React toolchain is not your cup of tea, Stencil may be worth a look.

Why not just use React?

Software development is an art form - there is no single correct answer on how something should be implemented.

It is important to remember that React components and web components are not the same. React component is a function that can take parameters; it returns React element, which then renders to HTML code.

Web component is an encapsulated reusable bit of HTML code.

Both have their purpose and can be used together in the same project.

However, using native web components provides portability and reduces dependency on externally maintained development frameworks while maintaining consistent branding.

Is that important to your project or company? It's up to developers to recommend the best solution. Frankly, if your shop uses the same tooling for all projects and that tooling already provides the ability to build components - or something component-like, then there is no significant benefit in switching. Are you working with a hodgepodge of frameworks? Then you should take a closer look at web components.