Implementation: Vanilla Web Components

Do it the standard way

Jérôme Beau
4 min readJul 5, 2024

Web components are a great way to rely to standard APIs instead of frameworks… provided you don’t rely on a Web component framework 🤯

What is it?

A web component is a custom element, that is, a custom HTML tag that you will provide implementation of. As for other tags, it usually implies defining your own HTML template in a “sub-DOM” that is specific to your component. As this DOM is usually not accessible from the regular DOM, it is called a “shadow DOM” (and the regular DOM a “light dom”).

The user agent shadow DOM of a standard search input exposed as a composition of two divs

We’ll describe below how to create your <my-component>own web component, by intention.

Instantiation

Instantiating an custom element instance is just asking the document to create an element with your custom tag. This can be done either declaratively (don’t forget the closing tag):

<my-component></my-component>

or programmatically:

const myComp1 = document.createElement("my-component")

then you’ll be able to use it just like any other HTML element. For instance:

document.body.append(myComp1)
myComp1.classList.add("warning")

However, this won’t work until the document knows what to do when asked to create such a tag. This implies two preliminary steps:

  1. developing a custom element implementation;
  2. associating this implementation with your custom tag.

Implementation

Defining an “autonomous” custom element is as simple as extending the base HTMLElement class:

class MyComponentElement extends HTMLElement {
}

That’s the type that we expect to get when invoking document.createElement("my-component"). You also noticed that the createElement() API does not allow to provide element’s constructor arguments. This means that all custom elements (and HTML elements in general) can only have a default, parameterless constructor.

How to initialize

So how do we provide initialization arguments, then? Like for any other tags, using attributes. Once again, either declaratively:

<my-component myprop="propValue"></my-component>

or programmatically:

myComp1.setAttribute("myprop", "propValue")

Also, because attributes can change anytime, you should listen to them:

class MyComponentElement extends HTMLElement {

static observedAttributes = ["myprop"]

attributeChangedCallback (name, oldValue, newValue) {
if (name === "myprop") {
this.renderMyComponentAgainUsing(newValue)
}
}
}

But attributes can only be strings, right? What if I need to provide my component some complex objects?

Actually, nothing prevents you to define and call a public API on your component:

class MyComponentElement extends HTMLElement {

setComplexObject(obj) {
this.renderMyComponentAgainUsing(obj)
}
}

Then all you need to call it is a reference. Either from looking up the DOM:

/** @type MyComponentElement */
const myComp1 = document.querySelector("my-component")
myComp1.setComplexObject(new ComplexObject())

or because you have it in your code:

/** @type MyComponentElement */
const myComp1 = document.createElement("my-component")
myComp1.setComplexObject(new ComplexObject())

Finally, all of this creation + initialization stuff could be encapsulated in a convenient factory function (not related to the standard), either outside or even inside the component’s code:

class MyComponentElement extends HTMLElement {
/**
* @param {string} propValue
* @param {ComplexObject} obj
* @return MyComponentElement
*/
static create(propValue, obj) {
const instance = document.createElement("my-component")
instance.setAttribute("myprop", propValue)
instance.setComplexObject(obj)
return instance
}
}

Template

As we saw at the beginning of this article, a web component usually have its own “shadow” DOM. If you don’t want it to be accessible from the outside (and you should), keep it closed:

class MyComponentElement extends HTMLElement {

constructor() {
this.shadow = this.attachShadow({ mode: "closed" })
}
}

This shadow DOM should be filled with the component’s inner HTML contents. This is usually done by cloning a template:

const template = document.createElement("template")
template.innerHTML = `<input><br>`
this.shadow.appendChild(template.content.cloneNode(true))

Of course this template could contain other web components.

Style

Since everything is encapsulated, the style of the inner DOM is typically provided through a <style> tag in the template:

template.innerHTML = `<style>input{color:red}</style><input><br>`

That doesn’t mean you cannot load that style from an external CSS file, though, thanks to the ability to import any file. However, to make sure it will not be parsed as JavaScript code, you need to import it as a raw file (or use import assertions).

/** @type {string} */
import style from "./MyCustomComponent.css?raw"

template.innerHTML = `<style>${style}</style><input><br>`

Also, note that some CSS nesting is not (yet) supported for shadow DOM.

Connection

There is a slight difference between instantiating a component declaratively or programmatically: the former instantiates the component and implicitly connects it at some place in the document, while the latter only provides you an unconnected instance. You need to use someParentEl.append(myComp1) or someParentEl.insert(myComp1) to connect it to the DOM.

This “connection” time is the time when you’ll be able to inspect your parent element, children, siblings, and so on. Not before. This is also the time when you’ll be able to look for any attributes set on you, as a component. For instance:

class MyComponentElement extends HTMLElement {

connectedCallback () {
if (this.hasAttribute("myprop")) {
this.myprop = this.getAttribute("myprop")
}
}
}

You’ll even be able to detect when the instance is not part of the document anymore (typically when you replace the displayed contents), using disconnectedCallback().

Registration

Now that our custom element class is ready, we can associate it with the custom tag name of our choice, so that the document will know which class to instantiate when encountering that tag:

customElements.define("my-component", MyComponentElement)

Of course, you should do this only once. So if that code is possibly executed multiple times, make you won’t redefine the tag again:

if (!customElements.get("my-component")) {
customElements.define("my-component", MyComponentElement)
}

Conclusion

These are the basic steps to create a web component with only standard APIs already present in your browser.

More advanced topics could be described, like:

  • how a web component can also have custom states which can be used in forms or styling;
  • how to make a web component participate in a form validation;
  • how web components templates can include slots to be filled by third party HTML;
  • how web components style can be customized from the outside (using CSS variables and/or styling “parts”)
  • how “built-in” web components can be defined as specializations of standard elements (other than HTMLElement), using the “is” standard attribute.

--

--