We all have projects we’d instead not work on. The code has become unmanageable, the scope evolved, quick fixes applied on top of other fixes, and the structure collapsed under its weight of spaghetti code. Coding can be a messy business.

Projects benefit from using simple, independent modules which have a single responsibility. Modular code is encapsulated, so there’s less need to worry about the implementation. As long as you know what a module will output when given a set of inputs, you don’t necessarily need to understand how it achieved that goal.

Applying modular concepts to a single programming language is straightforward, but web development requires a diverse mix of technologies. Browsers parse HTML, CSS, and JavaScript to render the page’s content, styles, and functionality.

They don’t always mix easily because:

  • Related code can be split between three or more files, and
  • Global styles and JavaScript objects can interfere with each other in unexpected ways.

These problems are in addition to those encountered by language runtimes, frameworks, databases, and other dependencies used on the server.

Check Out Our Video Guide to Web Components

What Are Web Components?

A Web Component is a way to create an encapsulated, single-responsibility code block that can be reused on any page.

Consider the HTML <video> tag. Given a URL, a viewer can use controls such as play, pause, move back, move forward, and adjust the volume.

Styling and functionality are provided, although you can make modifications using various attributes and JavaScript API calls. Any number of <video> elements can be placed inside other tags, and they won’t conflict.

What if you require your own custom functionality? For example, an element showing the number of words on the page? There’s no HTML <wordcount> tag (yet).

Frameworks such as React and Vue.js allow developers to create web components where the content, styling, and functionality can be defined in a single JavaScript file. These solve many complex programming problems but bear in mind that:

  • You must learn how to use that framework and update your code as it evolves.
  • A component written for one framework is rarely compatible with another.
  • Frameworks rise and wane in popularity. You’ll become dependent on the whims and priorities of the development team and users.
  • Standard Web Components can add browser functionality, which is difficult to achieve in JavaScript alone (such as the Shadow DOM).

Fortunately, popular concepts introduced in libraries and frameworks usually make their way into web standards. It’s taken some time, but Web Components have arrived.

A Brief History of Web Components

Following many vendor-specific false starts, the concept of standard Web Components was first introduced by Alex Russell at the Fronteers Conference in 2011. Google’s Polymer library (a polyfill based on the current proposals) arrived two years later, but early implementations did not appear in Chrome and Safari until 2016.

Browser vendors took time to negotiate the details, but Web Components were added to Firefox in 2018 and Edge in 2020 (when Microsoft switched to the Chromium engine).

Understandably, few developers have been willing or able to adopt Web Components, but we have finally reached a good level of browser support with stable APIs. Not everything is perfect, but they’re an increasingly viable alternative to framework-based components.

Even if you’re not willing to dump your favorite just yet, Web Components are compatible with every framework, and the APIs will be supported for years to come.

Repositories of pre-built Web Components are available for everyone to take a look at:

…but writing your own code is more fun!

This tutorial provides a complete introduction to Web Components written without a JavaScript framework. You will learn what they are and how to adapt them for your web projects. You’ll need some knowledge of HTML5, CSS, and JavaScript.

Getting Started With Web Components

Web Components are custom HTML elements such as <hello-world></hello-world>. The name must contain a dash to never clash with elements officially supported in the HTML specification.

You must define an ES2015 class to control the element. It can be named anything, but HelloWorld is common practice. It must extend the HTMLElement interface, which represents the default properties and methods of every HTML element.

Note: Firefox allows you to extend specific HTML elements such as HTMLParagraphElement, HTMLImageElement, or HTMLButtonElement. This is not supported in other browsers and does not allow you to create a Shadow DOM.

To do anything useful, the class requires a method named connectedCallback() which is invoked when the element is added to a document:

class HelloWorld extends HTMLElement {

  // connect component
  connectedCallback() {
    this.textContent = 'Hello World!';
  }

}

In this example, the element’s text is set to “Hello World.”

The class must be registered with the CustomElementRegistry to define it as a handler for a specific element:

customElements.define( 'hello-world', HelloWorld );

The browser now associates the <hello-world> element with your HelloWorld class when your JavaScript is loaded (e.g. <script type="module" src="./helloworld.js"></script>).

You now have a custom element!

CodePen demonstration

This component can be styled in CSS like any other element:

hello-world {
  font-weight: bold;
  color: red;
}

Adding Attributes

This component isn’t beneficial since the same text is output regardless. Like any other element, we can add HTML attributes:

<hello-world name="Craig"></hello-world>

This could override the text so “Hello Craig!” is displayed. To achieve this, you can add a constructor() function to the HelloWorld class, which is run when each object is created. It must:

  1. call the super() method to initialize the parent HTMLElement, and
  2. make other initializations. In this case, we’ll define a name property that is set to a default of “World”:
class HelloWorld extends HTMLElement {

  constructor() {
    super();
    this.name = 'World';
  }

  // more code...

Your component only cares about the name attribute. A static observedAttributes() property should return an array of properties to observe:

// component attributes
static get observedAttributes() {
  return ['name'];
}

An attributeChangedCallback() method is called when an attribute is defined in the HTML or changed using JavaScript. It’s passed the property name, old value, and new value:

// attribute change
attributeChangedCallback(property, oldValue, newValue) {

  if (oldValue === newValue) return;
  this[ property ] = newValue;

}

In this example, only the name property would ever be updated, but you could add additional properties as necessary.

Finally, you need to tweak the message in the connectedCallback() method:

// connect component
connectedCallback() {

  this.textContent = `Hello ${ this.name }!`;

}

CodePen demonstration

Lifecycle Methods

The browser automatically calls six methods throughout the lifecycle of the Web Component state. The full list is provided here, although you have already seen the first four in the examples above:

constructor()

It’s called when the component is first initialized. It must call super() and can set any defaults or perform other pre-rendering processes.

static observedAttributes()

Returns an array of attributes that the browser will observe.

attributeChangedCallback(propertyName, oldValue, newValue)

Called whenever an observed attribute is changed. Those defined in HTML are passed immediately, but JavaScript can modify them:

document.querySelector('hello-world').setAttribute('name', 'Everyone');

The method may need to trigger a re-render when this occurs.

connectedCallback()

This function is called when the Web Component is appended to a Document Object Model. It should run any required rendering.

disconnectedCallback()

It’s called when the Web Component is removed from a Document Object Model. This may be useful if you need to clean up, such as removing stored state or aborting Ajax requests.

adoptedCallback()

This function is called when a Web Component is moved from one document to another. You may find a use for this, although I’ve struggled to think of any cases!

How Web Components Interact With Other Elements

Web Components offer some unique functionality you won’t find in JavaScript frameworks.

The Shadow DOM

While the Web Component we’ve built above works, it’s not immune to outside interference, and CSS or JavaScript could modify it. Similarly, the styles you define for your component could leak out and affect others.

The Shadow DOM solves this encapsulation problem by attaching a separated DOM to the Web Component with:

const shadow = this.attachShadow({ mode: 'closed' });

The mode can either be:

  1. “open” — JavaScript in the outer page can access the Shadow DOM (using Element.shadowRoot), or
  2. “closed” — the Shadow DOM can only be accessed within the Web Component.

The Shadow DOM can be manipulated like any other DOM element:

connectedCallback() {

  const shadow = this.attachShadow({ mode: 'closed' });

  shadow.innerHTML = `
    <style>
      p {
        text-align: center;
        font-weight: normal;
        padding: 1em;
        margin: 0 0 2em 0;
        background-color: #eee;
        border: 1px solid #666;
      }
    </style>

    <p>Hello ${ this.name }!</p>`;

}

The component now renders the “Hello” text inside a <p> element and styles it. It cannot be modified by JavaScript or CSS outside the component, although some styles such as the font and color are inherited from the page because they were not explicitly defined.

CodePen demonstration

The styles scoped to this Web Component cannot affect other paragraphs on the page or even other <hello-world> components.

Note that the CSS :host selector can style the outer <hello-world> element from within the Web Component:

:host {
  transform: rotate(180deg);
}

You can also set styles to be applied when the element uses a specific class, e.g. <hello-world class="rotate90">:

:host(.rotate90) {
  transform: rotate(90deg);
}

HTML Templates

Defining HTML inside a script can become impractical for more complex Web Components. A template allows you to define a chunk of HTML in your page that your Web Component can use. This has several benefits:

  1. You can tweak HTML code without having to rewrite strings inside your JavaScript.
  2. Components can be customized without having to create separate JavaScript classes for each type.
  3. It’s easier to define HTML in HTML — and it can be modified on the server or client before the component renders.

Templates are defined in a <template> tag, and it’s practical to assign an ID so you can reference it within the component class. This example three paragraphs to display the “Hello” message:

<template id="hello-world">

  <style>
    p {
      text-align: center;
      font-weight: normal;
      padding: 0.5em;
      margin: 1px 0;
      background-color: #eee;
      border: 1px solid #666;
    }
  </style>

  <p class="hw-text"></p>
  <p class="hw-text"></p>
  <p class="hw-text"></p>

</template>

The Web Component class can access this template, get its content, and clone the elements to ensure you’re creating a unique DOM fragment everywhere it’s used:

const template = document.getElementById('hello-world').content.cloneNode(true);

The DOM can be modified and added directly to the Shadow DOM:

connectedCallback() {

  const

    shadow = this.attachShadow({ mode: 'closed' }),
    template = document.getElementById('hello-world').content.cloneNode(true),
    hwMsg = `Hello ${ this.name }`;

  Array.from( template.querySelectorAll('.hw-text') )
    .forEach( n => n.textContent = hwMsg );

  shadow.append( template );

}

CodePen demonstration

Template Slots

Slots allow you to customize a template. Presume you wanted to use your <hello-world> Web Component but place the message within a <h1> heading in the Shadow DOM. You could write this code:

<hello-world name="Craig">

  <h1 slot="msgtext">Hello Default!</h1>

</hello-world>

(Note the slot attribute.)

You could optionally want to add other elements such as another paragraph:

<hello-world name="Craig">

  <h1 slot="msgtext">Hello Default!</h1>
  <p>This text will become part of the component.</p>

</hello-world>

Slots can now be implemented within your template:

<template id="hello-world">

  <slot name="msgtext" class="hw-text"></slot>

  <slot></slot>

</template>

An element slot attribute set to “msgtext” (the <h1>) is inserted at the point where there’s a <slot> named “msgtext.” The <p> does not have a slot name assigned, but it is used in the next available unnamed <slot>. In effect, the template becomes:

<template id="hello-world">

  <slot name="msgtext" class="hw-text">
    <h1 slot="msgtext">Hello Default!</h1>
  </slot>

  <slot>
    <p>This text will become part of the component.</p>
  </slot>

</template>

It’s not quite this simple in reality. A <slot> element in the Shadow DOM points to the inserted elements. You can only access them by locating a <slot> then using the .assignedNodes() method to return an array of inner children. The updated connectedCallback() method:

connectedCallback() {

  const
    shadow = this.attachShadow({ mode: 'closed' }),
    hwMsg = `Hello ${ this.name }`;

  // append shadow DOM
  shadow.append(
    document.getElementById('hello-world').content.cloneNode(true)
  );

  // find all slots with a hw-text class
  Array.from( shadow.querySelectorAll('slot.hw-text') )

    // update first assignedNode in slot
    .forEach( n => n.assignedNodes()[0].textContent = hwMsg );

}

CodePen demonstration

In addition, you cannot directly style the inserted elements, although you can target specific slots within your Web Component:

<template id="hello-world">

  <style>
    slot[name="msgtext"] { color: green; }
  </style>

  <slot name="msgtext" class="hw-text"></slot>
  <slot></slot>

</template>

Template slots are a little unusual, but one benefit is that your content will be shown if JavaScript fails to run. This code shows a default heading and paragraph that are only replaced when the Web Component class successfully executes:

<hello-world name="Craig">

  <h1 slot="msgtext">Hello Default!</h1>
  <p>This text will become part of the component.</p>

</hello-world>

Therefore, you could implement some form of progressive enhancement — even if it’s just a “You need JavaScript” message!

The Declarative Shadow DOM

The examples above construct a Shadow DOM using JavaScript. That remains the only option, but an experimental declarative Shadow DOM is being developed for Chrome. This allows Server-Side Rendering and avoids any layout shifts or flashes of unstyled content.

The following code is detected by the HTML parser, which creates an identical Shadow DOM to the one you created in the last section (you’d need to update the message as necessary):

<hello-world name="Craig">

  <template shadowroot="closed">
    <slot name="msgtext" class="hw-text"></slot>
    <slot></slot>
  </template>

  <h1 slot="msgtext">Hello Default!</h1>
  <p>This text will become part of the component.</p>

</hello-world>

The feature is not available in any browser, and there’s no guarantee it’ll reach Firefox or Safari. You can find out more about the declarative Shadow DOM, and a polyfill is simple, but be aware that the implementation could change.

Shadow DOM Events

Your Web Component can attach events to any element in the Shadow DOM just like you would in the page DOM, such as to listen for click events on all inner children:

shadow.addEventListener('click', e => {

  // do something

});

Unless you stopPropagation, the event will bubble up into the page DOM, but the event will be retargeted. Hence, it appears to come from your custom element rather than elements within it.

Using Web Components in Other Frameworks

Any Web Component you create will work in all JavaScript frameworks. None of them know or care about HTML elements — your <hello-world> component will be treated identically to a <div> and placed into the DOM where the class will activate.

custom-elements-everywhere.com provides a list of frameworks and Web Component notes. Most are fully compatible, although React.js has some challenges. It’s possible to use <hello-world> in JSX:

import React from 'react';
import ReactDOM from 'react-dom';
import from './hello-world.js';

function MyPage() {

  return (
    <>
      <hello-world name="Craig"></hello-world> 
    </>
  );

}

ReactDOM.render(<MyPage />, document.getElementById('root'));

…but:

  • React can only pass primitive data types to HTML attributes (not arrays or objects)
  • React cannot listen for Web Component events, so you must manually attach your own handlers.

Web Component Criticisms and Issues

Web Components have improved significantly, but some aspects can be tricky to manage.

Styling Difficulties

Styling Web Components poses some challenges, especially if you want to override scoped styles. There are many solutions:

  1. Avoid using the Shadow DOM. You could append content directly to your custom element, although any other JavaScript could accidentally or maliciously change it.
  2. Use the :host classes. As we saw above, scoped CSS can apply specific styles when a class is applied to the custom element.
  3. Check out CSS custom properties (variables). Custom properties cascade into Web Components so, if your element uses var(--my-color), you can set --my-color in an outer container (such as :root), and it’ll be used.
  4. Take advantage of shadow parts. The new ::part() selector can style an inner component that has a part attribute, i.e. <h1 part="heading"> inside a <hello-world> component can be styled with the selector hello-world::part(heading).
  5. Pass in a string of styles. You can pass them as an attribute to apply within a <style> block.

None is ideal, and you’ll need to plan how other users can customize your Web Component carefully.

Ignored Inputs

Any <input>, <textarea>, or <select> fields in your Shadow DOM are not automatically associated within the containing form. Early Web Component adopters would add hidden fields to the page DOM or use the FormData interface to update values. Neither are particularly practical and break Web Component encapsulation.

The new ElementInternals interface allows a Web Component to hook into forms so custom values and validity can be defined. It’s implemented in Chrome, but a polyfill is available for other browsers.

To demonstrate, you’ll create a basic <input-age name="your-age"></input-age> component. The class must have a static formAssociated value set true and, optionally, a formAssociatedCallback() method can be called when the outer form is associated:

// <input-age> web component
class InputAge extends HTMLElement {

  static formAssociated = true;

  formAssociatedCallback(form) {
    console.log('form associated:', form.id);
  }

The constructor must now run the attachInternals() method, which allows the component to communicate with the form and other JavaScript code which wants to inspect the value or validation:

  constructor() {

    super();
    this.internals = this.attachInternals();
    this.setValue('');

  }

  // set form value

  setValue(v) {

    this.value = v;

    this.internals.setFormValue(v);

  }

The ElementInternal’s setFormValue() method sets the element’s value for the parent form initialized with an empty string here (it can also be passed a FormData object with multiple name/value pairs). Other properties and methods include:

  • form: the parent form
  • labels: an array of elements that label the component
  • Constraint Validation API options such as willValidate, checkValidity, and validationMessage

The connectedCallback() method creates a Shadow DOM as before, but must also monitor the field for changes, so setFormValue() can be run:

  connectedCallback() {

    const shadow = this.attachShadow({ mode: 'closed' });

    shadow.innerHTML = `
      <style>input { width: 4em; }</style>
      <input type="number" placeholder="age" min="18" max="120" />`;

    // monitor input values
    shadow.querySelector('input').addEventListener('input', e => {
      this.setValue(e.target.value);
    });

  }

You can now create an HTML form using this Web Component which acts in a similar way to other form fields:

<form id="myform">

  <input type="text" name="your-name" placeholder="name" />

  <input-age name="your-age"></input-age>

  <button>submit</button>

</form>

It works, but it admittedly feels a little convoluted.

Check it out in the CodePen demonstration

For more information, refer to this article on more capable form controls.

Summary

Web Components have struggled to gain agreement and adoption at a time when JavaScript frameworks have grown in stature and capability. If you’re coming from React, Vue.js, or Angular, Web Components can look complex and clunky, especially when you’re missing features such as data-binding and state management.

There are kinks to iron out, but the future for Web Components is bright. They’re framework-agnostic, lightweight, fast, and can implement functionality that would be impossible in JavaScript alone.

A decade ago, few would have tackled a site without jQuery, but browser vendors took the excellent parts and added native alternatives (such as querySelector). The same will happen for JavaScript frameworks, and Web Components is that first tentative step.

Do you have any questions about how to use Web Components? Let’s talk about it in the comments section!

Craig Buckler

Freelance UK web developer, writer, and speaker. Has been around a long time and rants about standards and performance.