Web Components

Web Components are a part of the HTML standard that allows the front-end developer to build component-based web applications.

Component based application development has become very popular with tool-kits like React, Angular and Vue. The idea is that we break down the appearance and functionality of our web application into components that can be developed and tested more or less in isolation. A component might be the banner heading on your page, a table to display data, a form to interact with the user, or a card to display product information. You write each of these as components and then put them together to create your whole application.

Web Components provides a way to do this using core web technologies rather than third-party tools. The capability is built into every modern browser, so you can just write Javascript code linked to an HTML page to make use of it. The idea actually makes use of three separate standards:

  • Custom elements - the ability to define a new HTML element like <card-display> and associate Javascript code with it to be run when the element is used in a document.
  • Shadow DOM - the ability to have a separate DOM representation behind a custom element so that the HTML and CSS used by that element don't interfere with the main page.
  • HTML Templates - a built in template mechanism to make building re-usable HTML structures easier.

Custom Elements

The HTML standard allows us to use any element name in a page, even if it isn't defined in the standard. So I can invent a new element <spot></spot> and use it in my document. The browser will not know what to do with it so will effectively treat it like <span></span> - it will not affect the page layout at all but will appear in the DOM. I can attach a CSS style rule to this new element and the browser will render it just like it was a 'real' element:

<spot style="color: blue; background-color: yellow;">Out Damn Spot!</spot>

In the HTML standard, an unknown element like this is interpreted as HTMLUnkownElement which means that while it is allowed, it doesn't have the same DOM interface as a real element (HTMLElement). However, a special type of unknown element is treated differently, that is those that have a valid custom element name; that is, one containing a hyphen (dash) character like <my-spot>, <info-card> or <login-form>. These elements are treated as valid elements and the custom element standard means that we can define the behaviour of these new elements with Javascript as well as their appearance with CSS.

Here's an example of a custom element with some style defined, at this point it is just like the <spot> element above.

<!DOCTYPE html>
<html>
    <head>
        <title>Web Components Sampler</title> 
        <style type="text/css">
            demo-component {
                display: block;
                background-color: red;
                width: 200px;
                height: 200px;
            }
        </style>
        <script src="script.js" defer></script>
    </head>
    <body>
        <h1>Web Component Sampler</h1>
        <demo-component>
            <p>Hello World</p>
        </demo-component>
    </body>
</html>

Adding Javascript

The new element <demo-component> just acts as a wrapper around the paragraph content and will appear as a 200 pixel red square. The next part of defining a custom element is to attach some Javascript code that will be run at different points in the life-cycle of the element. This is done in the script.js file by defining a new class that extends the built-in HTMLElement class. Here is a simple example.

class DemoComponent extends HTMLElement {

    connectedCallback () {
        this.innerHTML = `
        <h2>DEMO</h2>
        <p class="demo">I am a demonstration of custom elements!</p>`;
        console.log('rendered', this);
    }
}

customElements.define('demo-component', DemoComponent);

The class DemoComponent defines one method connectedCallback which is code that will be called whenever this element is inserted into the DOM - that is when an HTML file containing your element is parsed or some Javascript code inserts the element into an existing DOM. In my example above, I modify the innerHTML of the element to have some new content which will replace the 'Hello World' paragraph that was already there. I used a Javascript string template to do this (note the backticks ``` rather than single quote around the string) since these can be multi-lined and later will allow inserting variables into the content.

The console.log line will output to the browser console when this code is called. When you run this code, check the console and inspect the element that is printed out.

The last line of Javascript is critical as this is where we associate the DemoComponent class with the <demo-component> element. The names are similar by convention but we are just attaching a class to an element name. Note that the element name must start with a lower case letter and consist of lower case letters including unicode characters, numbers, '.' or '_' and must contain one '-' character. This means you can have special characters like <math-α> or <emotion-😍> but not eg. <Capital-x> or <spot> (Reference).

Adding Style

In my example, the CSS style associated with the new element is contained in the main page. It would be better to have the layout of the new component be defined within the Javscript code to make it more modular and stand-alone. We know that we can modify the style of any HTML element from Javascript so we just do that now in this next version:

class DemoComponent extends HTMLElement {
    connectedCallback () {
        console.log('rendered', this);
        this.innerHTML = `
        <h2>DEMO</h2>
        <p class="demo">I am a demonstration of custom elements!</p>`;
        this.style = `
            display: block;
            background-color: red;
            width: 200px;
            height: 200px;
        `
    }
}

Now the <style> block in the main page is not needed. When the new element is created we can insert the style directly into the element and our implementation is now self contained.

Note that this way of adding style is different to the stylesheet rule that we had before in that it only applies to this instance of <demo-component> rather than all of these elements in the page.

Element Properties

So far, the custom element will be the same every time it is used. It would be more useful to be able to control aspects of the element when it is created. We can do this using element attributes. Let's build a version of the new element where we can optionally control the size and colour of the square via an input attribute. In use it will look like this:

    <body>
        <h1>Web Component Sampler</h1>
        <demo-component size="300px"></demo-component>
        <demo-component size="120px" color="pink"></demo-component>
    </body>

(I'm using the US spelling of colour for consistency with other CSS attribute names). To implement this we need to access the attributes of the element and use them when we generate the element style in the connectedCallback. The first thing though is to introduce the constructor of the class which will be called when the element is created. In the constructor we will save the values from the attributes, setting defaults if they are not present.

In the connectedCallback we use the values of the size and color attributes to insert into the CSS string value that we use to update the element style attribute. We can also insert these values into the innerHTML content:

class DemoComponent extends HTMLElement {

    constructor () {
        super();
        this.size = this.getAttribute('size') || '200px';
        this.color = this.getAttribute('color') || 'red';
        console.log('constructed', this.size, this.color);
    }

    connectedCallback () {
        console.log('rendered', this);
        this.innerHTML = `
        <h2>DEMO</h2>
        <p class="demo">I am a demonstration of custom elements!</p>
        <p>Size is ${this.size}. Colour is ${this.color}.`;
        this.style = `
            display: block;
            background-color: ${this.color};
            width: ${this.size};
            height: ${this.size};
        `
    }
}

customElements.define('demo-component', DemoComponent);

In the constructor, we first call the super() function - this ensures that the constructor of the parent class (HTMLElement) is called (this has to be done explicitly in Javascript). We then use an or-expression to store either the value of the attributes or the default if that is not set.

Shadow DOM

The purpose of the Shadow DOM is to provide encapsulation - isolating the internal workings of our custom element from the rest of the DOM structure. In the first example of why this is an advantage we'll show how it 'protects' the internal parts of our element from global stylesheets.

Here's the example custom element modified to use the shadow DOM:

class ShadowDemoComponent extends HTMLElement {

    constructor () {
        super();
        this.size = this.getAttribute('size') || '200px';
        this.color = this.getAttribute('color') || 'red';
        this.attachShadow({ mode: 'open'});
        console.log('constructed', this.size, this.color);
    }

    connectedCallback () {
        console.log('rendered', this);
        const wrapper = document.createElement('div');
        wrapper.innerHTML = `
        <h2>DEMO</h2>
        <p class="demo">I am a demonstration of custom elements!</p>
        <p>Size is ${this.size}. Colour is ${this.color}.`;

        const style = document.createElement('style');
        style.textContent = `
        :host {
            display: block;
            background-color: ${this.color};
            width: ${this.size};
            height: ${this.size};
        }
        p {
            font-family: sans-serif;
        }
        `
        this.shadowRoot.append(style, wrapper);
    }
}

customElements.define('demo-shadow', ShadowDemoComponent);

In the constructor, we call this.attachShadow to attach a new shadow DOM to this new element. We use {mode: 'open'} which will allow the internals to be accessed from outside Javascript code ({mode: 'closed'} would block this).

In the connectedCallback, instead of modifying the innerHTML of the custom element we create a new wrapper element (a <div>) and insert the content into that. While setting the style attribute of the custom element would still work, we take a different approach here and create a <style> element and set the text content to a mini-stylesheet for our new element. The :host selector in the stylesheet refers to the host element of the shadow DOM - our custom element.

The stylesheet rule for paragraphs in the custom element will only apply to paragraphs within the shadow DOM, not to any paragraphs in the outside document. In addition, any rules for paragraphs in the main document won't apply inside the shadow DOM. If we modify the main HTML page as follows to provide a global paragraph style, it will apply to the original <demo-component> element but not to our new shadow DOM version.

<!DOCTYPE html>
<html>
    <head>
        <title>Web Components Sampler</title> 
        <script src="script.js" defer></script>
        <style type="text/css">
            .container {
                display: flex;
            }
            demo-component {
                margin: 10px;
            }
            p {
                font-size: 25pt;
            }
        </style>
    </head>
    <body>
        <h1>Web Component Sampler</h1>
        <div class="container">
            <demo-component size="300px"></demo-component>
            <demo-shadow size="200px" color="pink"></demo-shadow>
            <demo-component size="300px" color="yellow"></demo-component>
        </div>
    </body>
</html>

Here's a screenshot of the result:

Shadow DOM screenshot.

The two original components contain paragraphs that are affected by the global style rule. The shadow component is not and the local paragraph rule applies to change the font family.

So, the shadow DOM provides an enhanced level of encapsulation to our custom element which will insulate it from changes to the global stylesheet and potentially protect it from being modified by global Javascript (if it is declared closed).

Templates

The final part of the web components puzzle is the HTML <template> element that provides a way to construct new HTML fragments from templates. I won't say much about this here because in this text we'll use a different way of building HTML (the string templates used above and later we'll look at the lit library).

A template is written as part of the HTML page as follows:

<template id="sample-template">
    <style>
    :host {
        display: block;
        background-color: cyan;
        width: 200px;
        height: 200px;
    }
    </style>
    <h2>Template</h2>
    <p class="demo">I am a demonstration of custom elements!</p>
    <slot name="body-text">Default body text</slot>
</template>

This is just a regular HTML element but when the browser sees it in the page it will not be rendered. However, our Javascript code can find the template using the id attribute and this is how we make use of it. The <slot> element is special as will be explained below.

To use the template our custom element finds it in the DOM and makes a copy to insert into the shadow DOM:

class TemplateComponent extends HTMLElement {
    constructor () {
        super();
        this.attachShadow({ mode: 'open'});
        console.log('constructed template component');
    }

    connectedCallback () {
        console.log('rendered', this);
        const template = document.getElementById('sample-template').content;
        this.shadowRoot.append(template.cloneNode(true));
    }
}

customElements.define('template-component', TemplateComponent);

The Javascript code is similar to the previous examples but we use the standard DOM method getElementById to retrieve the template and then get its content. We then insert a clone of that DOM node into the shadow DOM (the true argument means it should be a deep clone of the template). The result is similar to before, the shadow DOM now has the style and content that will appear in the page.

The difference with this template is the <slot> element which is a placeholder for content. Unlike the variables in our other examples, the slot is filled by the user of the element rather than the Javascript code in the custom element class. Here's an example:

<template-component>
    <ul slot="body-text">
        <li>One</li>
        <li>Two</li>
    </ul>
</template-component>

The <ul> element here will be inserted where the <slot> element appears in the template.

Read more about the use of templates with more examples on the MDN site.