DEV Community

Cover image for Native Web Components Development
Helmuth Saatkamp for Mercedes-Benz.io

Posted on • Updated on • Originally published at Medium

Native Web Components Development

Have you ever asked yourself how many times you wrote a Button component using different libraries or frameworks? Components are the base block of any Web project, but with all the changes and new frameworks appearing, it can be hard to reuse or keep them updated. As a result, increasing the development time.

To solve this problem, Web Components can simplify this process since they work natively with the Browsers and can also be integrated into any JS Framework/Library.

You must understand HTML, CSS, and Javascript before getting started.

In the end, you will comprehend how to create and integrate a Web Component. I will provide links containing the example while sharing my experience, caveats, and solutions I found for the most common problems when starting to develop native Web Components.

What is a Web Component?

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

The Web Component technology is older and more used than most people know. The <audio>, <meter>, <video>, and many other HTML tags are implemented in each Browser with Web Component (like) technology. But, that technology was not available externally. So, what we now call "Web Components" (Custom Elements API, Templates, Shadow DOM) is that very same technology available to us all.

Building Blocks of a Web Component

The main features you need to understand to start creating your own components are:

For this tutorial, you are going to build an alert component.

Custom Alert Element

Shadow DOM

A key aspect of web components is encapsulation — keeping the markup structure, style, and behavior hidden and separate from other code on the page so that different parts do not clash and the code can be kept nice and clean. The Shadow DOM API is crucial, providing a way to attach a hidden separated DOM to an element.

Shadow DOM allows hidden DOM trees to be attached to elements in the regular DOM tree — this shadow DOM tree starts with a shadow root, underneath which can be attached to any elements you want, in the same way as the standard DOM.

ShadowDOM Abstraction

In simple terms, shadow DOMs are self-contained, encapsulated blocks of code within a regular DOM that have their own scope.

HTML Templates

The HTML Templates are where you create and add the HTML markup and the CSS. You just have to write your markup inside the <template> tag to use it.

The different aspect of the template is that it will be parsed but not rendered, so the template will appear in the DOM but not be presented on the page. To understand it better, let's look at the example below.

<template>
  <div class="alert">
    <span class="alert__text">
      <slot></slot>
    </span>
    <button id="close-button" type="button" class="alert__button">x</button>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

Since you don't have native support to import HTML files into the JavaScript code, the easier way to achieve this is to add a template tag via code in the JavaScript file and assign the HTML content to the innerHTML property.

const template = document.createElement('template');
template.innerHTML = /*html*/ `
<div class="alert">
  <span class="alert__text">
    <slot></slot>
  </span>
  <button id="close-button" type="button" class="alert__button">x</button>
</div>`;
Enter fullscreen mode Exit fullscreen mode

Show code in action

This is the draft of the component you will build and this is the result after registering and importing it:

ce-alert preview

<ce-alert>Hello there!</ce-alert>
Enter fullscreen mode Exit fullscreen mode

I will explain the details on how to register and import it later and also how to add CSS styles. Furthermore, you probably notice a new tag called <slot> which is an important feature of the Web Component technology, so let's check it out.

The <slot> element

The <slot> element is a placeholder inside a web component that you can fill with your own markup, which lets you create separate DOM trees and present them together, and can only be used with the Shadow DOM. The name attribute can be used to specify the target of content you want to place.

Let's look into this example. You have created a new Web Component called ce-article and it contains the following markup:

<article>
  <header>
    <slot name="header">
      <h1>title</h2>
    </slot>
    <slot name="subheader">
      <h2>subtitle</h2>
    </slot>
  </header>
  <p>
    <slot></slot>
  </p>
  <footer>
    <slot name="footer"></slot>
  </footer>
</article>
Enter fullscreen mode Exit fullscreen mode

Show code in action

To make use of this component you could declare it as follows:

<ce-article>
  <h1 slot="header">My articles title</h1>
  Loren ipsum neros victus...
  <a href="#" slot="footer">Read more</a>
</ce-article>
Enter fullscreen mode Exit fullscreen mode

Show code in action

Then all the content will be placed in the position you declare inside your Web Component as demonstrated by the image below.

ce-article preview

Custom Element

To create Custom Elements, you need to define the name and a class object representing the element's behavior. As a rule of thumb, you should add a prefix to the component to avoid clashes with the native HTML tags, and also note that custom element names must contain a hyphen. So, in the example, you could add ce (custom element) prefix for the component, like ce-alert.

Create a new Custom Element

Create a new class Alert inherited from HTMLElement and call the base constructor with the super inside the constructor method.

const template = document.createElement('template');
//...
export class Alert extends HTMLElement {
  constructor() {
    super();
  }
}
Enter fullscreen mode Exit fullscreen mode

Show code in action

Register a new Custom Element

Next, you use the customElements.define method to register your new component.

const template = document.createElement('template');
//...
export class Alert extends HTMLElement {
  //...
}
customElements.define('ce-alert', Alert);
Enter fullscreen mode Exit fullscreen mode

Show code in action

Custom Element Lifecycle

From the moment you create, update, or remove a custom element it fires specific methods to define each stage.

Custom Element Lifecycle

  • connectedCallback: Invoked each time the custom element is appended into a document-connected element. Each time the node is moved, this may happen before the element's contents have been fully parsed.
  • disconnectedCallback: Invoked each time the custom element is disconnected from the document's DOM.
  • adoptedCallback: Invoked each time the custom element is moved to a new document.
  • attributeChangedCallback: Invoked each time one of the custom element's attributes is added, removed, or changed. Which attributes to notice a change is specified in a static get observedAttributes method

Let's look at an example of these concepts in use.

// https://github.com/mdn/web-components-examples/tree/main/life-cycle-callbacks
//...
class Square extends HTMLElement {
  // Specify observed attributes so that attributeChangedCallback will work
  static get observedAttributes() {
    return ['c', 'l'];
  }
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    const div = document.createElement('div');
    const style = document.createElement('style');
    shadow.appendChild(style);
    shadow.appendChild(div);
  }
  connectedCallback() {
    console.log('Custom square element added to page.');
    updateStyle(this);
  }
  disconnectedCallback() {
    console.log('Custom square element removed from page.');
  }
  adoptedCallback() {
    console.log('Custom square element moved to new page.');
  }
  attributeChangedCallback(name, oldValue, newValue) {
    console.log('Custom square element attributes changed.');
    updateStyle(this);
  }
}
//...
Enter fullscreen mode Exit fullscreen mode

Show code in action

The class constructor is simple - here, just attach a shadow DOM to the custom element, then you can append your template inside it. The shadow mode can be open or closed. In the open state, the element can be accessed outside the shadow root or vise-versa.

To access an element inside a custom element, you need to do query select using the custom element name, use the shadowRoot prop, then query again for the element you want.

document.querySelector('ce-alert').shadowRoot.querySelector('#close-button');
Enter fullscreen mode Exit fullscreen mode

NOTE: This is only possible when the mode is set to open while attaching the shadow root to your custom element.

To recap, the updates are all handled by the life cycle callbacks, which are placed inside the class definition as methods. The connectedCallback() runs each time the element is added to the DOM. The disconnectedCallback runs when the element is removed and the attributeChangedCallback() is called when an attribute (which is mapped in the static get observedAttributes() method) is changed.

TIP: To check whether a component is connected to the DOM, you can use this.isConnected

Define attributes and properties

Attributes and properties work slightly differently from what you used to understand in a JS library/framework. Attributes are what you declare inside the HTML tag, and properties are part of the HTMLElement class you extended, and when you define a new component, it already contains a set of properties defined. So sync attributes and properties can be achieved by reflecting properties to attributes. Let's demonstrate that with the example:

<ce-alert color="red"></ce-alert>
Enter fullscreen mode Exit fullscreen mode

It is crucial to notice that attributes are always strings. Therefore, you cannot define a method, object, or number. But, in case you need another type, you have to cast it later or declare it directly inside the element object.

Now to sync the attribute with the property in the class:

//...
export class Alert extends HTMLElement {
  //...
  set color(value) {
    this.setAttribute('color', value);
  }

  get color() {
    return this.getAttribute('color');
  }

  connectedCallback() {
    console.log(this.color); // outputs: "red"
  }
}
//...
Enter fullscreen mode Exit fullscreen mode

Show code in action

Although this approach works, it can become lengthy or tedious as more and more properties your components have. But, there is an alternative that does not require declaring all properties manually: The HTMLElement.datasets interface provides read/write access to custom data attributes (data-*) on elements. It exposes a map of strings (DOMStringMap) with each data-* attribute entry, you can also combine it with the get/set properties to have even more flexibility. Let’s update the example with the dataset declaration:

<ce-alert data-color="red"></ce-alert>
Enter fullscreen mode Exit fullscreen mode
//...
export class Alert extends HTMLElement {
  //...
  attributeChangedCallback() {
    console.log(this.dataset.color); // outputs: "red"
  }
}
//...
Enter fullscreen mode Exit fullscreen mode

Show code in action

Sync Properties and Attributes (Bonus)

This is optional, but in case you want to do the sync between attributes and properties, here is a function that can simplify this process:

/**
 * @param target - the custom element class
 * @param props - properties that need to be synced with the attributes
 */
const defineProperties = (target, props) => {
  Object.defineProperties(
    target,
    Object.keys(props).reduce((acc, key) => {
      acc[key] = {
        enumerable: true,
        configurable: true,
        get: () => {
          const attr = target.getAttribute(getAttrName(key));
          return (attr === '' ? true : attr) ?? props[key];
        },
        set: val => {
          if (val === '' || val) {
            target.setAttribute(getAttrName(key), val === true ? '' : val);
          } else {
            target.removeAttribute(key);
          }
        }
      };
      return acc;
    }, {})
  );
};
Enter fullscreen mode Exit fullscreen mode

Observe Properties and Attributes

To detect attributes or property changes, you need to return an array with all values you want using the static method observedAttributes. Next, you configure the callback function attributeChangedCallback to define what will happen when the defined property changes.

//...
export class Alert extends HTMLElement {
  //...
  static get observedAttributes() {
    return ['data-color'];
  }
  attributeChangedCallback(name, prev, curr) {
    if (prev !== curr) {
      this.shadowRoot.querySelector('.alert').classList.remove(prev);
      this.shadowRoot.querySelector('.alert').classList.add(curr);
    }
  }
}
//...
Enter fullscreen mode Exit fullscreen mode

Show code in action

Browser Integration

You can now use your Custom Element in your HTML file. To integrate, you must import the js file as a module.

<html>
  <head>
    <style>
      ...;
    </style>
    <script type="module" src="./index.js"></script>
  </head>
  <body>
    <ce-alert></ce-alert>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Show code in action

Element Styling

In a Web Component, there are at least four ways of defining a style using CSS:

In addition to the conventional CSS selectors, Web Components supports the following ones:

Selector Description
:host/:host(name) Selects the shadow host element or if it has a certain class.
:host-context(name) Selects the shadow host element only if the selector given as the function's parameter matches the shadow host's ancestor(s) in the place it sits inside the DOM hierarchy.
::slotted() Selects a slotted element if it matches the selector.
::part() Selects any element within a shadow tree with a matching part attribute.

Inline Style

The initial (and most common) way for you could start styling your components is to declare the styles inside the template.

<template>
  <style>
    :host {
      --bg-color: #ffffff;
      --border-color: #d4d4d8;
      --text-color: #374151;
    }
    .alert {
      font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
      display: flex;
      justify-content: space-between;
      align-items: center;
      padding: 0.5rem 1.25rem;
      color: var(--text-color);
      background-color: var(--background-color);
      border: 1px solid var(--border-color);
      border-radius: 0.75rem;
    }
    .alert__text {
      font-size: 0.875rem;
      line-height: 1.25rem;
    }
    .alert__button {
      -webkit-appearance: button;
      cursor: pointer;
      color: var(--text-color);
      background-color: transparent;
      background-image: none;
      border: none;
      height: 2rem;
      width: 2rem;
      margin-left: 0.25rem;
    }
  </style>
  <div class="alert">
    <span class="alert__text">
      <slot></slot>
    </span>
    <button id="close-button" type="button" class="alert__button">x</button>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

Show code in action

The main difference here is the use of the :host selector instead of the :root selector which is not available inside the encapsulated element and cannot access global CSS variables inside the Web Component.

Using the "part" attribute

Another solution is to use the ::part selector to customize a component from the outside, making it possible to use the :root selector and create shared styles. You need to add the part attribute to the elements you want to customize, then the CSS selectors from the outside can reach in.

Let's take a look at this example, you could update the template and change the class attribute to part.

<template>
  <style>
    //...
  </style>
  <div part="alert">
    <span part="text">
      <slot></slot>
    </span>
    <button id="close-button" type="button" part="button">x</button>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

Show code in action

Then, create a new CSS file and move all the style blocks into it and update the selectors to match the ce-alert component.

:root {
  --bg-color: #ffffff;
  --border-color: #d4d4d8;
  --text-color: #374151;
  font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
}

ce-alert::part(alert) {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0.5rem 1.25rem;
  color: var(--text-color);
  border: 1px solid var(--border-color);
  background-color: var(--background-color);
  border-radius: 0.75rem;
}

ce-alert::part(text) {
  font-size: 0.875rem;
  line-height: 1.25rem;
}

ce-alert::part(button) {
  -webkit-appearance: button;
  color: var(--text-color);
  background-color: transparent;
  background-image: none;
  border: none;
  margin-left: 0.25rem;
  height: 2rem;
  width: 2rem;
  cursor: pointer;
}
Enter fullscreen mode Exit fullscreen mode

Show code in action

NOTE: The ::part selector only accepts one parameter.

To finalize, update the index.html file to import this new CSS file, and that's it.

CSS Inject

Another way to customize the elements is to inject the styles inside the Web Component. First, you must create a CSSStyleSheet object that represents a single CSS stylesheet, then replace the styles and finally apply them to the shadow root. The only downside is that it requires a special polyfill to work with safari.

const stylesheet = new CSSStyleSheet();
stylesheet.replace('body { font-size: 1rem };p { color: gray; };');
this.shadowRoot.adoptedStyleSheets = [stylesheet];
Enter fullscreen mode Exit fullscreen mode

Show code in action

You can combine it with a JS Bundler and enable PostCSS features. You need to configure it to load the CSS files as string.

Vite

If you are using Vite, append the raw suffix to import as string.

import styles from './ce-alert.css?raw';
Enter fullscreen mode Exit fullscreen mode

Show code in action

Webpack

In case you are using Webpack, you have to install postcss, postcss-loader, and raw-loader :

npm install --save-dev postcss postcss-loader raw-loader
Enter fullscreen mode Exit fullscreen mode

Afterward, update the webpack.config.js file to import the CSS files as string.

module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ['raw-loader', 'postcss-loader']
      }
    ]
  }
};
Enter fullscreen mode Exit fullscreen mode

Show code in action

Link Reference

Link Reference is my preferred solution because you can load external CSS files without having to duplicate any code and can even be used to integrate your Web Component with a popular CSS Framework like Tailwind, Bulma, or Bootstrap.

For this example, you will integrate Tailwind with Vite. After following the setup instructions, create a tailwind.css file in the root level of the project:

@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

Install concurrently and configure the package.json to run the tailwind compiler together with the dev server:

npm install --save-dev concurrently
Enter fullscreen mode Exit fullscreen mode
{
  "name": "vite-starter",
  "private": true,
  "version": "0.0.0",
  "scripts": {
    "start": "concurrently --kill-others-on-fail \"npm:dev\" \"npm:tailwind\"",
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "tailwind": "tailwindcss -i ./tailwind.css -o ./public/tailwind.css --watch"
  },
  "devDependencies": {
    "@tailwindcss/typography": "^0.5.2",
    "autoprefixer": "^10.4.5",
    "concurrently": "^7.1.0",
    "postcss": "^8.4.12",
    "postcss-import": "^14.1.0",
    "postcss-nesting": "^10.1.4",
    "tailwindcss": "^3.0.24",
    "vite": "^2.9.6"
  }
}
Enter fullscreen mode Exit fullscreen mode

After that, update the index.html to include the styles and load the scripts as a module:

...
<head>
 ...
 <link href="/tailwind.css" rel="stylesheet" />
 ...
</head>
<body>
 ...
 <script src="/src/main.ts" type="module"></script>
</body>
Enter fullscreen mode Exit fullscreen mode

Show code in action

Now, inside your Web Component you can link the CSS library:

<template>
  <link rel="stylesheet" href="/tailwind.css" />
  <div
    class="flex items-center justify-between rounded-xl border border-contrast-300 bg-canvas py-2 pl-4 pr-3 text-sm text-content shadow-sm">
    <span class="text-sm">
      <slot></slot>
    </span>
    <button
      id="close-button"
      type="button"
      class="ml-1 -mr-1 inline-flex h-8 w-8 items-center justify-center p-0.5 text-current">
      x
    </button>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

Show code in action

Final Solution

Here is the result of your new Web Component with everything you learned so far:

const template = document.createElement('template');
template.innerHTML = /*html*/ `
<style>
  :host {
    --bg-color: #ffffff;
    --border-color: #d4d4d8;
    --text-color: #374151;
  }
  .alert {
    font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 0.5rem 1.25rem;
    color: var(--text-color);
    background-color: var(--background-color);
    border: 1px solid var(--border-color);
    border-radius: 0.75rem;
  }
  .alert__text {
    font-size: 0.875rem;
    line-height: 1.25rem;
  }
  .alert__button {
    -webkit-appearance: button;
    cursor: pointer;
    color: var(--text-color);
    background-color: transparent;
    background-image: none;
    border: none;
    height: 2rem;
    width: 2rem;
    margin-left: 0.25rem;
  }
</style>
<div class="alert">
  <span class="alert__text">
    <slot></slot>
  </span>
  <button id="close-button" type="button" class="alert__button">x</button>
</div>`;

export class Alert extends HTMLElement {
  static get observedAttributes() {
    return ['data-color'];
  }
  constructor() {
   super()  // sets AND returns 'this' scope
      .attachShadow({ mode: 'open' })  // sets AND returns this.shadowRoot
      .append(template.content.cloneNode(true));
  }
  connectedCallback() {
    const button = this.shadowRoot.getElementById(`close-button`);
    button.addEventListener(
      'click',
      () => {
        this.dispatchEvent(new CustomEvent('close'));
        this.remove();
      },
      { once: true }
    );
  }
  attributeChangedCallback(name, prev, curr) {
    if (prev !== curr) {
      this.shadowRoot.querySelector('.alert').classList.remove(prev);
      this.shadowRoot.querySelector('.alert').classList.add(curr);
    }
  }
}

customElements.define('ce-alert', Alert);
Enter fullscreen mode Exit fullscreen mode

Show code in action

Problems and Issues

There are good aspects of using Web Components as it can work everywhere, is small, and runs faster as it uses built-in platform APIs. But it is not only flowers, and there are also some things which might not work as you expected.

Attributes vs Properties

A downside of using attributes in a custom element is that it accepts only strings, and syncing the properties with the attributes requires manual declaration.

Component Update

Custom elements can detect if an attribute changes, but what happens next is up to the developer to define.

Styling

Styling can be problematic and tricky since the component is encapsulated and components like dropdowns, popups, or tooltips that require dynamic elements on top of others can become challenging to implement.

Accessibility

Because of Shadow DOM boundary, common attributes like label/for, tab-index, aria-pressed, and role are not working as you expect. But, there is an alternative using the new browser API called accessibility object model.

Forms

Using forms with custom elements requires some custom form association to make it work.

SSR Support

Due to the nature of a Web Component, it cannot be used in an SSR page since Web Components rely on browser-specific DOM APIs, and the Shadow DOM cannot be represented declarative, so it cannot be sent as string format.

Conclusion

In this article, you learned about the world of Web Components, which consists of three blocks: HTML Template, Shadow DOM, and Custom Elements. Combining them makes it possible to create your Custom HTML Elements that can be reused in many other applications.

To get a little more information about building Web Components, you can check the webcomponents.dev website, where you can discover and play with different ways of making Web Components.

Try it out, play with it, and create your first Web Component for your application.

Top comments (3)

Collapse
 
dannyengelman profile image
Danny Engelman • Edited

Before:

constructor() {
    super();
    this.shadow = this.attachShadow({ mode: 'open' });
    shadow.appendChild(template.content.cloneNode(true));
  }
Enter fullscreen mode Exit fullscreen mode

After:

constructor() {
   super()  // sets AND returns 'this' scope
      .attachShadow({ mode: 'open' })  // sets AND returns this.shadowRoot
      .append(template.content.cloneNode(true));
}
Enter fullscreen mode Exit fullscreen mode

Dev.to: 5 more lessons after Web Components #101

Collapse
 
helmuthdu profile image
Helmuth Saatkamp

Hi Danny,
Thanks for the hint, I have also updated the article. ;)

Collapse
 
eerk profile image
eerk

If you don't mind the CSS being NOT encapsulated, you can even use web components WITHOUT a shadow root. It's still a nice way to link javascript code to UI elements. The CSS can still be in a global file.