An Introduction to Frameworkless Web Components

    Craig Buckler
    Share

    This tutorial provides an introduction to standard web components written without using a JavaScript framework. You’ll learn what they are and how to adopt them for your own web projects. A reasonable knowledge of HTML5, CSS, and JavaScript is necessary.

    What are Web Components?

    Ideally, your development project should use simple, independent modules. Each should have a clear single responsibility. The code is encapsulated: it’s only necessary to know what will be output given a set of input parameters. Other developers shouldn’t need to examine the implementation (unless there’s a bug, of course).

    Most languages encourage use of modules and reusable code, but browser development requires a mix of HTML, CSS, and JavaScript to render content, styles, and functionality. Related code can be split across multiple files and may conflict in unexpected ways.

    JavaScript frameworks and libraries such as React, Vue, Svelte, and Angular have alleviated some of the headaches by introducing their own componentized methods. Related HTML, CSS, and JavaScript can be combined into one file. Unfortunately:

    • it’s another thing to learn
    • frameworks evolve and updates often incur code refactoring or even rewriting
    • a component written in one framework won’t usually work in another, and
    • frameworks can be heavy and limited by what is achievable in JavaScript

    A decade ago, many of the concepts introduced by jQuery were added to browsers (such as querySelector, closest, classList, and so on). Today, vendors are implementing web components that work natively in the browser without a framework.

    It’s taken some time. Alex Russell made the initial proposal in 2011. Google’s (polyfill) Polymer framework arrived in 2013, but three years passed before native implementations appeared in Chrome and Safari. There were some fraught negotiations, but Firefox added support in 2018, followed by Edge (Chromium version) in 2020.

    How do Web Components Work?

    Consider the HTML5 <video> and <audio> elements, which allow users to play, pause, rewind, and fast-forward media using a series of internal buttons and controls. By default, the browser handles the layout, styling, and functionality.

    Web components allow you to add your own HTML custom elements — such as a <word-count> tag to count the number of words in the page. The element name must contain a hyphen (-) to guarantee it will never clash with an official HTML element.

    An ES2015 JavaScript class is then registered for your custom element. It can append DOM elements such as buttons, headings, paragraphs, etc. To ensure these can’t conflict with the rest of the page, you can attach them to an internal Shadow DOM that has it’s own scoped styles. You can think of it like a mini <iframe>, although CSS properties such as fonts and colors are inherited through the cascade.

    Finally, you can append content to your Shadow DOM with reusable HTML templates, which offer some configuration via <slot> tags.

    Standard web components are rudimentary when compared with frameworks. They don’t include functionality such as data-binding and state management, but web components have some compelling advantages:

    • they’re lightweight and fast
    • they can implement functionality that’s impossible in JavaScript alone (such as the Shadow DOM)
    • they work inside any JavaScript framework
    • they’ll be supported for years — if not decades

    Your First Web Component

    To get started, add a <hello-world></hello-world> element to any web page. (The closing tag is essential: you can’t define a self-closing <hello-world /> tag.)

    Create a script file named hello-world.js and load it from the same HTML page (ES modules are automatically deferred, so it can be placed anywhere — but the earlier in the page, the better):

    <script type="module" src="./hello-world.js"></script>
    
    <hello-world></hello-world>
    

    Create a HelloWorld class in your script file:

    // <hello-world> Web Component
    class HelloWorld extends HTMLElement {
    
      connectedCallback() {
        this.textContent = 'Hello, World!';
      }
    
    }
    

    Web components must extend the HTMLElement interface, which implements the default properties and methods of every HTML element.

    Note: Firefox can extend specific elements such as HTMLImageElement and HTMLButtonElement. However, these don’t support the Shadow DOM, and this practice isn’t supported in other browsers.

    The browser runs the connectedCallback() method whenever the custom element is added to a document. In this case, it changes the inner text. (A Shadow DOM isn’t used.)

    The class must be registered with your custom element in the CustomElementRegistry:

    // register <hello-world> with the HelloWorld class
    customElements.define( 'hello-world', HelloWorld );
    

    Load the page and “Hello World” appears. The new element can be styled in CSS using a hello-world { ... } selector:

    See the Pen
    <hello-world> component
    by SitePoint (@SitePoint)
    on CodePen.

    Create a <word-count> Component

    A <word-count> component is more sophisticated. This example can generate the number of words or the number of minutes to read the article. The Internationalization API can be used to output numbers in the correct format.

    The following element attributes can be added:

    • round="N": rounds the number of words to the nearest N (default 10)
    • minutes: shows reading minutes rather than a word count (default false)
    • wpm="M": the number of words a person can read per minute (default 200)
    • locale="L": the locale, such as en-US or fr-FR (default from <html> lang attribute, or en-US when not available)

    Any number of <word-count> elements can be added to a page. For example:

    <p>
      This article has
      <word-count round="100"></word-count> words,
      and takes
      <word-count minutes></word-count> minutes to read.
    </p>
    

    WordCount Constructor

    A new WordCount class is created in a JavaScript module named word-count.js:

    class WordCount extends HTMLElement {
    
      // cached word count
      static words = 0;
    
      constructor() {
    
        super();
    
        // defaults
        this.locale = document.documentElement.getAttribute('lang') || 'en-US';
        this.round = 10;
        this.wpm = 200;
        this.minutes = false;
    
        // attach shadow DOM
        this.shadow = this.attachShadow({ mode: 'open' });
    
      }
    

    The static words property stores a count of the number of words in the page. This is calculated once and reused so other <word-count> elements don’t need to repeat the work.

    The constructor() function is run when each object is created. It must call the super() method to initialize the parent HTMLElement and can then set other defaults as necessary.

    Attach a Shadow DOM

    The constructor also defines a Shadow DOM with attachShadow() and stores a reference in the shadow property so it can be used at any time.

    The mode can be set to:

    • "open": JavaScript in the outer page can access the Shadow DOM using Element.shadowRoot, or
    • "closed": the Shadow DOM is inaccessible to the outer page

    This component appends plain text, and outside modifications aren’t critical. Using open is adequate so other JavaScript on the page can query the content. For example:

    const wordCount = document.querySelector('word-count').shadowRoot.textContent;
    

    Observing WordCount Attributes

    Any number of attributes can be added to this Web Component, but it’s only concerned with the four listed above. A static observedAttributes() property returns an array of properties to observe:

      // component attributes
      static get observedAttributes() {
        return ['locale', 'round', 'minutes', 'wpm'];
      }
    

    An attributeChangedCallback() method is invoked when any of these attributes is set in HTML or JavaScript’s .setAttribute() method. It’s passed the property name, the previous value, and new value:

      // attribute change
      attributeChangedCallback(property, oldValue, newValue) {
    
        // update property
        if (oldValue === newValue) return;
        this[property] = newValue || 1;
    
        // update existing
        if (WordCount.words) this.updateCount();
    
      }
    

    The this.updateCount(); call renders the component so it can be re-run if an attribute is changed after it’s displayed for the first time.

    WordCount Rendering

    The connectedCallback() method is invoked when the Web Component is appended to a Document Object Model. It should run any required rendering:

      // connect component
      connectedCallback() {
        this.updateCount();
      }
    

    Two other functions are available during the lifecycle of the Web Component, although they’re not necessary here:

    • disconnectedCallback(): called when the Web Component is removed from a Document Object Model. It could clean up state, aborting Ajax requests, etc.
    • adoptedCallback(): called when a Web Component is moved from one document to another. I’ve never found a use for it!

    The updateCount() method calculates the word count if that’s not been done before. It uses the content of the first <main> tag or the page <body> when that’s not available:

      // update count message
      updateCount() {
    
        if (!WordCount.words) {
    
          // get root <main> or </body>
          let element = document.getElementsByTagName('main');
          element = element.length ? element[0] : document.body;
    
          // do word count
          WordCount.words = element.textContent.trim().replace(/\s+/g, ' ').split(' ').length;
    
        }
    

    It then updates the Shadow DOM with the word count or minute count (if the minutes attribute is set):

        // locale
        const localeNum = new Intl.NumberFormat( this.locale );
    
        // output word or minute count
        this.shadow.textContent = localeNum.format(
          this.minutes ?
            Math.ceil( WordCount.words / this.wpm ) :
            Math.ceil( WordCount.words / this.round ) * this.round
        );
    
      }
    
    } // end of class
    

    The Web Component class is then registered:

    // register component
    window.customElements.define( 'word-count', WordCount );
    

    See the Pen
    <word-count> component
    by SitePoint (@SitePoint)
    on CodePen.

    Uncovering the Shadow DOM

    The Shadow DOM is manipulated like any other DOM element, but it has a few secrets and gotchas …

    Scoped Styling

    Styles set in the Shadow DOM are scoped to the Web Component. They can’t affect the outer page elements or be changed from the outside (although some properties such as the font-family, colors, and spacing can cascade through):

    const shadow = this.attachShadow({ mode: 'closed' });
    
    shadow.innerHTML = `
      <style>
        p {
          font-size: 5em;
          text-align: center;
          font-weight: normal;
          color: red;
        }
      </style>
    
      <p>Hello!</p>
    `;
    

    You can target the Web Component element itself using a :host selector:

    :host {
      background-color: green;
    }
    

    Styles can also be applied when a specific class is assigned to the Web Component, such as <my-component class="nocolor">:

    :host(.nocolor) {
      background-color: transparent;
    }
    

    Overriding the Shadow Styles

    Overriding scoped styles isn’t easy, and it probably shouldn’t be. There are a number of options if you want to offer a level of styling to consumers of your Web Component:

    • :host classes. As shown above, scoped CSS can apply styles when a class is applied to the custom element. This could be useful for offering a limited choice of styling options.

    • CSS custom properties (variables). Custom properties cascade into web components. If your component uses var(—color1), you can set —color1 in any parent container all the way up to :root. You may need to avoid name clashes, perhaps using namespaced variables such as —my-component-color1.

    • Use Shadow parts. The ::part() selector can style an inner component with a part attribute. For example, <h1 part="head"> inside <my-component> component is stylable using my-component::part(head) { ... }.

    • Pass styling attributes. A string of styles can be set in one or more Web Component attributes, which can be applied during the render. It feels a little dirty but it’ll work.

    • Avoid the Shadow DOM. You could append DOM content directly to your custom element without using a Shadow DOM, although it could then conflict with or break other components on the page.

    Shadow DOM Events

    Web components can attach events to any element in the Shadow DOM just as you would in the page DOM. For example, to listen for a click on a button:

    shadow.querySelector('button').addEventListener('click', e => {
      // do something
    });
    

    The event will bubble up to the outer page DOM unless you prevent it with e.stopPropagation(). However, the event is retargeted; the outer page will know it occurred on your custom element but not which Shadow DOM element was the target.

    Using HTML templates

    Defining HTML inside your class can be impractical. HTML templates define a chunk of HTML in your page that can be used by any Web Component:

    • you can tweak HTML code on the client or server without having to rewrite strings in JavaScript classes
    • components are customizable without requiring separate JavaScript classes for each type

    HTML templates are defined inside a <template> tag. An ID is normally defined so you can reference it:

    <template id="my-template">
      <style>
        h1, p {
          text-align: center;
        }
      </style>
      <h1><h1>
      <p></p>
    </template>
    

    The class code can fetch this template’s .content and clone it to make a unique DOM fragment before making modifications and updating the Shadow DOM:

    connectedCallback() {
    
      const
        shadow = this.attachShadow({ mode: 'closed' }),
        template = document.getElementById('my-template').content.cloneNode(true);
    
      template.querySelector('h1').textContent = 'heading';
      template.querySelector('p').textContent = 'text';
    
      shadow.append( template );
    
    }
    

    Using Template Slots

    Templates can be customized with <slot>. For example, you might create the following template:

    <template id="my-template">
      <slot name="heading"></slot>
      <slot></slot>
    </template>
    

    Then define a component:

    <my-component>
      <h1 slot="heading">default heading<h1>
      <p>default text</p>
    </my-component>
    

    An element with the slot attribute set to "heading" (the <h1>) is inserted into the template where <slot name="heading"> appears. The <p> doesn’t have a slot name, but it’s used in the next available unnamed . The resulting template looks like this:

    <template id="my-template">
      <slot name="heading">
        <h1 slot="heading">default heading<h1>
      </slot>
      <slot>
        <p>default text</p>
      </slot>
    </template>
    

    In reality, each <slot> points to its inserted elements. You must locate a <slot> in the Shadow DOM, then use .assignedNodes() to return an array of child nodes which can be modified. For example:

    const shadow = this.attachShadow({ mode: 'closed' }),
    
    // append template with slots to shadow DOM
    shadow.append(
      document.getElementById('my-template').content.cloneNode(true)
    );
    
    // update heading
    shadow.querySelector('slot[name="heading"]')
      .assignedNodes()[0]
      .textContent = 'My new heading';
    

    It’s not possible to directly style the inserted elements, although you can target slots and have CSS properties cascade through:

    slot[name="heading"] {
      color: #123;
    }
    

    Slots are a little cumbersome, but the content is shown before your component’s JavaScript runs. They could be used for rudimentary progressive enhancement.

    The Declarative Shadow DOM

    A Shadow DOM can’t be constructed until your JavaScript runs. The Declarative Shadow DOM is a new experimental feature that detects and renders the component template during the HTML parsing phase. A Shadow DOM can be declared server side and it helps avoid layout shifts and flashes of unstyled content.

    A component is defined with an internal <template> that has a shadowroot attribute set to open or closed as appropriate:

    <mycompnent>
    
      <template shadowroot="closed">
        <slot name="heading">
          <h1 slot="heading">default heading<h1>
        </slot>
        <slot>
          <p>default text</p>
        </slot>
      </template>
    
      <h1 slot="heading">default heading<h1>
      <p>default text</p>
    
    </my-component>
    

    The Shadow DOM is then ready when the component class runs; it can update the content as necessary.

    The feature is coming to Chrome-based browsers, but it’s not ready yet and there’s no guarantee it’ll be supported in Firefox or Safari (although it polyfills easily). For more information, refer to Declarative Shadow DOM.

    Inclusive Inputs

    <input>, <textarea>, and <select> fields used in a Shadow DOM are not associated with the containing form. Some Web Component authors add hidden fields to the outer page DOM or use the FormData interface to update values — but these break encapsulation.

    A new ElementInternals interface allows web components to associate themselves with forms. It’s implemented in Chrome but a polyfill is required for other browsers.

    Let’s say you’ve created an <input-age name="your-age"></input-age> component which appends the following field into the Shadow DOM:

    <input type="number" placeholder="age" min="18" max="120" />
    

    The Web Component class must define a static formAssociated property as true and can optionally provide a formAssociatedCallback() method that’s is called when a form is associated with the control:

    class InputAge extends HTMLElement {
    
      static formAssociated = true;
    
      formAssociatedCallback(form) {
        console.log('form associated:', form.id);
      }
    

    The constructor must run this.attachInternals() so the component can communicate with the form and other JavaScript code. The ElementInternal setFormValue() method sets the element’s value (initialized as an empty string):

      constructor() {
        super();
        this.internals = this.attachInternals();
        this.setValue('');
      }
    
      // set field value
      setValue(v) {
        this.value = v;
        this.internals.setFormValue(v);
      }
    

    The connectedCallback() method renders the Shadow DOM as before, but it must also watch the field for changes and update the value:

      connectedCallback() {
    
        const shadow = this.attachShadow({ mode: 'closed' });
    
        shadow.innerHTML = `
          <style>input { width: 4em; }</style>
          <input type="number" placeholder="age" min="18" max="120" />`;
    
        // monitor age input
        shadow.querySelector('input').addEventListener('change', e => {
          this.setValue(e.target.value);
        });
    
      }
    

    ElementInternal can also provide information about the form, labels, and Constraint Validation API options.

    See the Pen
    form web component
    by SitePoint (@SitePoint)
    on CodePen.

    For more information, refer to web.dev’s “More capable form controls”.

    Are Web Components the Future?

    If you’re coming from a JavaScript framework, web components may seem low-level and a somewhat cumbersome. It’s also taken a decade to gain agreement and achieve a reasonable level of cross-browser compatibility. Unsurprisingly, developers haven’t been eager to use them.

    Web components aren’t perfect, but they’ll evolve and they’re usable today. They’re lightweight, fast, and offer functionality that’s impossible in JavaScript alone. What’s more, they can be used in any JavaScript framework. Perhaps consider them for your next project?

    Links and resoruces

    Here are some pre-built Web Component examples and repositories:

    For more information on browser support for the various elements of web components, check out the following data from the caniuse site:

    Finally, if you’d like to dive deeper into this topic and learn how to how to build a complete app with web components, check out our tutorial “Build a Web App with Modern JavaScript and Web Components”.