An Introduction to Frameworkless Web Components

Share this article

An Introduction to Frameworkless Web Components

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”.

Frequently Asked Questions (FAQs) about Frameworkless Web Components

What are the benefits of using frameworkless web components?

Frameworkless web components offer several advantages. Firstly, they are lightweight and fast because they don’t carry the overhead of a framework. This can lead to improved performance and faster load times for your website or application. Secondly, they are highly portable and can be used across different projects, regardless of the underlying technology stack. This can save development time and effort. Lastly, they adhere to web standards, ensuring compatibility and interoperability across different browsers and platforms.

How do I create a frameworkless web component?

Creating a frameworkless web component involves using the Web Components standards, which include Custom Elements and Shadow DOM. Custom Elements allow you to define your own HTML elements, while Shadow DOM encapsulates your component’s styles and markup to prevent conflicts with other styles on the page. You can create a new custom element by extending the HTMLElement class and then defining your element using the customElements.define() method.

Can I use frameworkless web components with existing frameworks?

Yes, you can use frameworkless web components with existing frameworks like React, Angular, or Vue. This allows you to leverage the benefits of web components while still using the tools and libraries you are familiar with. However, integration might require additional steps or considerations, such as handling data binding or component lifecycle events.

What are the challenges of using frameworkless web components?

While frameworkless web components offer many benefits, they also come with some challenges. For instance, they may lack the comprehensive tools and libraries provided by frameworks, which can make development more complex. Additionally, browser support for web components is not yet universal, although it is improving. Lastly, learning to work with web components can require a learning curve, especially for developers accustomed to working with frameworks.

How do I handle state management in frameworkless web components?

State management in frameworkless web components can be handled in various ways. One approach is to use the properties and attributes of your components to manage state. Another approach is to use events to communicate changes in state between components. You can also use libraries like Redux or MobX for more complex state management needs.

How do I test frameworkless web components?

Testing frameworkless web components can be done using standard JavaScript testing tools like Jest or Mocha. You can write unit tests for your components’ methods and properties, as well as integration tests to ensure your components work correctly together. Additionally, you can use tools like Puppeteer or jsdom for end-to-end testing and to simulate browser environments.

How do I style frameworkless web components?

Styling for frameworkless web components can be done using CSS, just like regular HTML elements. However, thanks to Shadow DOM, you can encapsulate your styles within your components, preventing style leakage and conflicts. You can also use CSS variables and custom properties to make your styles more dynamic and reusable.

Can I use third-party libraries with frameworkless web components?

Yes, you can use third-party libraries with frameworkless web components. However, you should be mindful of the size and performance impact of the libraries you choose, as one of the benefits of frameworkless web components is their lightweight nature.

How do I handle component lifecycle events in frameworkless web components?

Frameworkless web components provide lifecycle callbacks that you can use to handle component lifecycle events. These include connectedCallback (when the component is added to the DOM), disconnectedCallback (when the component is removed from the DOM), attributeChangedCallback (when an attribute of the component changes), and adoptedCallback (when the component is moved to a new document).

How do I ensure accessibility in frameworkless web components?

Ensuring accessibility in frameworkless web components involves following the same best practices as for regular HTML. This includes using semantic HTML, providing alternative text for images, ensuring sufficient color contrast, and making your components keyboard-accessible. You can also use ARIA attributes to improve accessibility where necessary.

Craig BucklerCraig Buckler
View Author

Craig is a freelance UK web consultant who built his first page for IE2.0 in 1995. Since that time he's been advocating standards, accessibility, and best-practice HTML5 techniques. He's created enterprise specifications, websites and online applications for companies and organisations including the UK Parliament, the European Parliament, the Department of Energy & Climate Change, Microsoft, and more. He's written more than 1,000 articles for SitePoint and you can find him @craigbuckler.

framework-lessweb components
Share this article
Read Next
Get the freshest news and resources for developers, designers and digital creators in your inbox each week