Craig Buckler
Craig Buckler's Web Tech Tutorials

Craig Buckler's Web Tech Tutorials

An Introduction to Native Web Components

An Introduction to Native Web Components

Craig Buckler's photo
Craig Buckler
·Sep 30, 2022·

16 min read

Featured on Hashnode

Subscribe to my newsletter and never miss my upcoming articles

Play this article

Table of contents

Popular JavaScript frameworks released in the past decade often use the concept of "components". These are simple, encapsulated modules with a single responsibility that you can combine to create complex web applications. A framework component may wrap HTML, CSS, and JavaScript in a single file isolated from other components so styles and functionality should not clash on a single page.

Framework-based components have some downsides:

  • You must learn that framework and update your code as it evolves. This is not always easy: ask anyone upgrading from Angular 1.

  • Frameworks rise and fall. Will the choice you make today still be viable next year? Perhaps even next month given the rapid evolution of JavaScript projects and techniques?

  • Components developed for one framework are not compatible with others. Even if they don't conflict, loading both frameworks affects page performance.

  • A framework can only implement what's possible in JavaScript today. Features such as a truly isolated (shadow) DOM are impossible.

What Are Native Web Components?

Native browser web components provide a way to create encapsulated, single-responsibility, custom functionality which can be used with or without a framework. In other words, you can create your own HTML tags such as <hello-world>.

It's best explained by considering an existing HTML element such as <video>. This plays a media file and provides user controls to play, pause, rewind, fast forward, adjust volume etc. Internally, these controls are child HTML elements such as <button> and <div> with styling and event handlers applied.

You can control a <video> by setting attributes in HTML or DOM properties in JavaScript such as autoplay to automatically start playing, poster to define a thumbnail image, or width and height to set dimensions. The inner HTML of the element cannot be directly inspected or changed: it's contained in a Shadow DOM and isolated from the rest of the page. You can place any number of <video> elements on a page: each is configurable but one video will not conflict with the operation of another.

This behavior is available to your own controls in native web components. The concepts were first introduced by Alex Russell at the Fronteers Conference in 2011. Google's Polymer library polyfill appeared in 2013 and initial implementations arrived in Chrome and Safari in 2016. More time elapsed while details were negotiated, but web components finally appeared in Firefox in 2018 and Edge in 2020 when Microsoft switched to the Chromium engine. They're now a web standard.

Browser support for web components is excellent but, understandably, few developers have been willing or able to adopt them given the time it's taken and the ubiquity of frameworks like React. You may be unwilling to dump your favorite framework but web components are viable and compatible with them all.

The following repositories provide a range of pre-built web components:

This tutorial provides a introduction to web components you can write without a JavaScript framework. Reasonable knowledge of HTML5, CSS, and JavaScript is essential.

Your First Web Component

Web components are custom HTML elements such as <hello-world></hello-world>. The name must contain a dash to ensure it will never clash with built-in future HTML elements. The closing element is also required even when is no inner content.

You must define an ES2015 class to control the element. Use any name you like but a camel-case version of the element name is common practice, e.g. HelloWorld. It must extend the HTMLElement interface which represents the default properties and methods of all HTML elements.

(Note that Firefox permits extending specific HTML elements such as HTMLParagraphElement, HTMLImageElement, and HTMLButtonElement but this is not supported in other browsers.)

The class requires a method named connectedCallback() which executes when the element is added to the HTML document. Assuming you're using ES modules, that occurs when the DOM content is ready:

class HelloWorld extends HTMLElement {

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

}

The code above sets the element's text to "Hello World".

You must register a web component class in the CustomElementRegistry as the handler an HTML element:

// use HelloWorld class for <hello-world> component
customElements.define( 'hello-world', HelloWorld );

If your JavaScript code is contained in hello-world.js, the new component can added to any HTML page:

<!-- load web component code -->
<script type="module" src="./hello-world.js"></script>

<!-- use web component -->
<hello-world></hello-world>

Like any other element, you can style a web component with CSS:

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

Attribute Handling

The <hello-world> component outputs the same content every time. You can add HTML attributes to make it more useful. This example outputs "Hello Craig!":

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

The HelloWorld class can use a constructor() function which executes when creating an object of that type. It must:

  1. call the super() method to execute the parent HTMLElement constructor, and

  2. run other initialization code. In this case, set a name property to "World" by default.

class HelloWorld extends HTMLElement {

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

  // more code...

Now add a static observedAttributes() property which returns an array of attributes to observe:

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

The browser calls an attributeChangedCallback() handler when setting the name attribute in HTML or using the JavaScript setAttribute() function. The function receives the property name, old value, and new value -- the code below sets it as an object .name property for easier use:

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

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

}

You now need to edit the connectedCallback() method to use the name in the output message:

// connect component
connectedCallback() {

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

}

Other Lifecycle Methods

The browser can call one of six handler methods depending on the current state of the web component:

  1. constructor()

    Called when creating the component object. It must call super() and typically sets defaults or executes other pre-rendering processes.

  2. static observedAttributes()

    Sets an array of attribute names which trigger attributeChangedCallback() when changed.

  3. attributeChangedCallback(propertyName, oldValue, newValue)

    The handler called when changing an observed attribute. The function may need to trigger a re-render when this occurs.

  4. connectedCallback()

    The handler called when adding the web component to the document. It typically renders the output.

  5. disconnectedCallback()

    The handler called when removing the web component from the document. It typically runs clean-up operations such as aborting in-flight Fetch() requests.

  6. adoptedCallback()

    The handler called when moving a web component from one document to another.

Using the Shadow DOM

Any CSS or JavaScript outside our class could change the output rendered by the <hello-world> components. Styles defined inside the component could also leak into other HTML elements.

To solve this problem, a Shadow DOM can isolate the inner web component elements from the page's Document Object Model (DOM). You can use it within the connectedCallback() function:

connectedCallback() {

  // create a Shadow DOM
  const shadow = this.attachShadow({ mode: 'closed' });

  // add elements to the Shadow DOM
  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 mode can be either:

  1. 'open': code outside the class can access the component's Shadow DOM using Element.shadowRoot, or

  2. 'closed': you can only manipulate the Shadow DOM within the web component class. Note that some CSS styles such as font-family, background-color, and color will cascade into the component like any other HTML element.

In this example, styles and functionality explicitly scoped within the web component cannot be overridden. The web component's styles will not affect other components.

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

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

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

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

Using HTML Templates

Building complex HTML structures with strings or DOM manipulation can become impractical. You can define HTML within <template> elements to use in a web component. This allows you to:

  • change HTML without having to rewrite JavaScript classes.
  • create variations of the same web component without requiring new JavaScript classes, and
  • edit HTML within HTML returned by the client or server.

The following example <template> sets styles and paragraphs to use inside the web component. It's given an ID so you can reference it when rendering:

<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 connectedCallback() function can access this template, get its contents, clone the child elements, and add a unique DOM fragment to the Shadow DOM:

connectedCallback() {

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

    // clone template into DOM fragment
    template = document.getElementById('hello-world').content.cloneNode(true),
    hwMsg = `Hello ${ this.name }`;

  // change message on all paragraphs
  Array.from( template.querySelectorAll('.hw-text') )
    .forEach( n => n.textContent = hwMsg );

  // append fragment to Shadow DOM
  shadow.append( template );

}

Using Template Slots

You can customize templates using slots defined in the web component HTML. For example, you could define a <hello-world> element with an <h1> heading that sets a slot attribute:

<hello-world name="Craig">

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

</hello-world>

You could optionally add further elements such as paragraphs:

<hello-world name="Craig">

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

</hello-world>

You can reference these slots in an HTML <template>:

<template id="hello-world">

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

  <slot></slot>

</template>

The following occurs:

  • The <slot name="msgtext"> placeholder inserts any element with a slot attribute set to "msgtext" (the <h1>).

  • The <slot> placeholder inserts the next unnamed element (the <p>).

The template therefore 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>

Note that each <slot> element inside the component's Shadow DOM does not contain child nodes. You can access them by locating the <slot> node and using its .assignedNodes() method to return an array of child elements:

connectedCallback() {

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

  // append template to 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 );

}

Note you cannot directly style slotted child elements. You must target specific slots and set styles to cascade through, e.g.

<template id="hello-world">

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

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

</template>

One benefit of using slots is that content renders before JavaScript runs (or if it fails to download and execute). The example above shows a default heading and paragraph and, assuming nothing goes wrong, your JavaScript can progressively enhance the output.

Using the Declarative Shadow DOM

The experimental declarative Shadow DOM in Chrome-based browsers allows server-side rendering before JavaScript rendering occurs. This can improve perceived performance, permit hydration-like techniques, and prevent layout shifts or flashes of unstyled content.

The browser creates an identical Shadow DOM to that shown above when it supports the declarative Shadow DOM:

<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 yet not available in Firefox or Safari although they will still render using JavaScript. See Declarative Shadow DOM for more information.

Handling Shadow DOM Events

Your web component can attach event handlers to any element in the Shadow DOM in the same way you would in a page, e.g.

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

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

  // user clicked web component

});

Unless you run the stopPropagation() method, the event will bubble up to the page DOM. However, the event object is retargeted so it appears to emit from your custom element rather than a child Shadow DOM element.

Using Web Components in Other Frameworks

All JavaScript frameworks support native web components. Frameworks do not know or care about HTML elements so <hello-world> activates as soon you place it into the page DOM. You could render it from JSX returned from a React component:

// React libraries
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';

// web component class
import from './hello-world.js';

const
  rootElement = document.getElementById('root'),
  root = createRoot(rootElement);

root.render(
  <StrictMode>
    <hello-world name="Craig"></hello-world>
  </StrictMode>
);

Most framework have full support for web components React has some limitations:

  • You can only pass primitive data types to HTML attributes -- not arrays and objects.
  • You cannot listen for web component events and must attach your own handlers.

Web Component Gotchas

Native web components incur development challenges you may not have encountered with frameworks. For example:

  • poor support for server-side and declarative Shadow DOM rendering
  • no framework-like magic such as data binding, and
  • few development tools. You're on your own with vanilla JavaScript.

The following sections highlight some difficulties with potential solutions.

Configurable Styles

You often want to override scoped web component styles. You could accept this restriction although there are workarounds such as:

  1. Do not use the Shadow DOM

    This would permit inner element modification and styling but it removes the safeguards. Other JavaScript or CSS code could accidentally or intentionally modify your component.

  2. Use :host classes

    Your scoped CSS can apply styles according to class attribute options (as described above).

  3. Use CSS custom properties

    Custom properties (CSS variables) cascade into web components. Your scoped CSS can reference a variable such as --my-color: #f00; set in an outer container up to the :root.

  4. Use shadow parts

    Web components can expose specific elements with a part attribute, e.g. <h1 part="heading">. You can target this element in page CSS hello-world::part(heading) and style it accordingly.

  5. Pass styles to component attributes

    You could permit one or more web component attributes on the which apply content directly (and somewhat dangerously) into an inner <style>.

No solution is perfect but always consider how other users may want to customize your component.

Handling Form Inputs

Shadow DOM <input>, <textarea>, and <select> fields are not associated with an outer form containing your web component. You can either:

  1. Add hidden fields to the page DOM which update when fields change. This may be difficult when using more than one web component of the same type.

  2. Intercept the form submit event and use the FormData interface to add, remove, or modify values. This is only possible if form submission occurs.

  3. Use the ElementInternals interface to add custom values and validity checks to a form. This has good support in all browsers except Safari although a polyfill is available.

Consider an <input-age> web component with an inner <input type="number"> in its shadow DOM. The class must set a static formAssociated property to true and provide an optional formAssociatedCallback() method which executes when an outer form registers:

class InputAge extends HTMLElement {

  static formAssociated = true;

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

The constructor must run an attachInternals() method to allow the component to communicate with the form and let other JavaScript code inspect values or validation criteria:

  constructor() {
    super();
    this.internals = this.attachInternals();
    this.setValue('');
  }

  // set form value
  setValue(v) {
    this.value = v;
    this.internals.setFormValue(v);
  }

The ElementInternal setFormValue() method sets the element's value for the parent form. The code above defines a single empty string but you can also pass a FormData object with multiple name/value pairs. Other ElementInternal properties and methods include:

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

The connectedCallback() method creates a Shadow DOM as before but it must also monitor internal form fields and call setValue() when changes occur:

  connectedCallback() {

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

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

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

  }

You can use the web component in any form where it acts much like a native field:

<form id="myform">

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

  <button>submit</button>

</form>

For more information, refer to More capable form controls.

Binding Attributes and Properties

Web component HTML attributes and JavaScript class properties are separate entities. Consider this component:

<my-component id="myc" name="Craig" job-title="developer"></my-component>

You can inspect or change an attribute:

const myc = document.getElementById('myc');

myc.getAttribute('name'); // Craig
myc.setAttribute('job-title', 'author');

but it's not possible to reference equivalent object properties:

const myc = document.getElementById('myc');

myc.name; // undefined
myc.jobTitle = 'author'; // does nothing

This can make rendering more complex. You must use:

connectedCallback() {
  const
    name = this.getAttribute('name'),
    jobTitle = this.getAttribute('job-title');

  this.textContent = `${ name } is a ${ jobTitle }.`;
}

rather than the simpler:

connectedCallback() {
  this.textContent = `${ this.name } is a ${ this.jobTitle }.`;
}

Fortunately, a we can bind attributes and properties together so it's possible to use either interchangeably. The class requires a private #camelCase() method to convert kebab case attributes (lowercase with or without hyphens) to camel case properties (mixed case without hyphens):

// component class
class MyComponent extends HTMLElement {

  static get observedAttributes() {
    return ['name', 'job-title'];
  }

  // convert camel-case attribute to camelCase property
  #attrToProp = {}; // camelCase cache
  #camelCase(attr) {

    let prop = this.#attrToProp[attr];

    if (!prop) {
      let np = attr.split('-');
      prop = [ np.shift(), ...np.map(n => n[0].toUpperCase() + n.slice(1)) ].join('');
      this.#attrToProp[attr] = prop;
    }

    return prop;

  }

}

The private #attrToProp object stores previous conversions so it's only necessary to calculate them once.

A private #defineProperties() method defines property setters and getters for the observedAttributes which fetch and update attributes:

  // set/get attribute when property is used
  #defineProperties() {

    const attributes = new Set([...this.constructor.observedAttributes]);
    attributes.forEach(attr => {

      Object.defineProperty(this, this.#camelCase(attr), {
        set: value => { this.setAttribute( attr, value ); },
        get: () => this.getAttribute( attr )
      });

    });

  }

Call this method in the constructor:

  constructor() {
    super();
    this.#defineProperties();
  }

Create a private #render() method to update the output using the more convenient property names:

  #render() {
    this.textContent = `${ this.name } is a ${ this.jobTitle }.`;
  }

Then call it within the attributeChangedCallback() and connectedCallback() methods:

  attributeChangedCallback(property, valueOld, value) {
    if (value == valueOld) return;
    this.#render();
  }

  connectedCallback() {
    this.#render();
  }

Experiment by opening the Codepen console and getting a reference to the component at the > prompt:

const myc = document.getElementById('myc');

You can examine values using properties or attributes:

myc.name; // Craig
myc.getAttribute('name'); // Craig

myc.jobTitle; // developer
myc.getAttribute('job-title'); // developer

Similarly, you can update values using properties or attributes:

myc.name = 'Alice';
myc.setAttribute('name', 'Bob');

myc.jobTitle = 'author';
myc.setAttribute('job-title', 'book keeper');

Either option re-renders the output because the attributeChangedCallback() method executes. You have implemented one-way data binding in an identical manner to popular JavaScript frameworks!

Conclusion

Native web components took some time to arrive and may seem clunky when compared against JavaScript frameworks which provide tools and built-in functionality. You may not want to drop React, Vue, or Svelte yet but native components offer some compelling advantages:

  • They're framework-agnostic. You can adopt web components today and use them within frameworks or vanilla JavaScript projects.

  • Web components are lightweight, fast, and truly isolated from the page via the Shadow DOM (frameworks take avoiding action against conflicts and may not always be successful).

  • Web component support should continue for decades because browser vendors avoid breaking the web. Can the same be said for any framework?

There are issues to iron out but web components are the future. The next generation of tools and frameworks will exploit and enhance web component functionality so I recommend you try them now.

Did you find this article valuable?

Support Craig Buckler by becoming a sponsor. Any amount is appreciated!

See recent sponsors Learn more about Hashnode Sponsors
 
Share this