Web Components & Custom Elements

Murtuzaali Surti
Murtuzaali Surti

• 5 min read

Those familiar with React or any other javascript framework, are already aware of the component based architecture. You break the UI into re-usable pieces of code and stitch them together when required.

Custom elements in HTML are a way to extend native HTML elements. Javascript frameworks simulate the behavior of components in a web page whereas custom elements provide a native HTML-ly way to do so. A web component uses custom elements along with other techniques such as the shadow DOM.

Types Of Custom Elements #

  • Autonomous custom elements
  • Customized built-in elements

Autonomous custom elements extend the generic HTMLElement class. On the other hand, a customized custom element extends a specific HTML elements' class and builds on top of existing functionality. For example, if you want a custom anchor element, you can extend the HTMLAnchorElement.

Defining Custom Elements #

To define a custom element, we need to create a javascript class extending the native HTMLElement class. Try creating the below custom element in a codepen:

class Demo extends HTMLElement {
  constructor() {
    super()
  }
  
  connectedCallback() {
    this.textContent = "hello"
  }
}

customElements.define("demo", Demo)

And then invoking it in HTML:

<demo></demo>

It won't let you create it. Why? See the error.

Uncaught SyntaxError: Failed to execute 'define' on 'CustomElementRegistry': "demo" is not a valid custom element name

This is not a bug, this is intentionally done to separate custom elements from native HTML elements. Custom elements must contain a hyphen in their name to make custom elements recognizable and distinct from HTML elements.

So now, if you change it to something like demo-webc and change the class name to DemoWebC, it works.

class DemoWebC extends HTMLElement {
  constructor() {
    super()
    this.customProperty = "custom"
  }
  
  connectedCallback() {
    this.textContent = "hello"
  }
}

// two arguments: tag name, class name
customElements.define("demo-webc", DemoWebC)

It always recommended to call the super() method first in the constructor as it initializes default properties of the HTMLElement class by invoking its constructor.

The connectedCallback() method is for detecting when the element is loaded into the page. There's also a method named disconnectedCallback() which detects if the element is removed from the page. A third method name adoptedCallback() says that the element has moved to a new page.

You can define custom properties inside the constructor and use them as custom attributes in your element.

constructor() {
    super()
    this.customProperty = {
        name: "data-custom",
        value: "custom value"
    }

    connectedCallback() {
        this.textContent = "hello"
        this.setAttribute(this.customAttribute.name, this.customAttribute.value)
    }
}

But what if you need to modify the functionality on attribute's value change? That's where attributeChangedCallback() method comes into action. In order to see it in action, you need to first define a static observedAttributes class property and set it to an array of all the attributes you want to keep track of.

The attributeChangedCallback() fires if those attributes mentioned in the static observedAttributes property change. Note that if the attribute is already present when the custom element loads, this method is fired at that time too.

static observedAttributes = ["data-custom"]

constructor() {
    super()
}

attributeChangedCallback(name, old, newValue) {
    console.log(name, old, newValue)
}

Once you are done with building a custom element, you must register it by using the define() method. It's callable on the customElements global object (window.customElements) which is a registry of custom elements.

customElements.define("custom-element-name", ClassName, options)

This was all for defining a customized autonomous custom element. What about extending only an anchor HTML element?

For that, instead of extending the HTMLElement class, extend the HTMLAnchorElement class. And specify which type of HTML element it extends with the extends option.

class DemoAnchor extends HTMLAnchorElement {
  constructor() {
    super()
  }
  
  connectedCallback() {
    this.textContent = "syntackle.com"
    this.href = "https://syntackle.com"
  }
}

customElements.define("demo-anchor", DemoAnchor, { extends: "a" })

You can't use this element like <demo-anchor> because it's not an autonomous element, instead you can use it like this:

<a is="demo-anchor"></a>

Web Components #

Web components are more than just custom elements. They sometimes also involve a shadow DOM. A "shadow" DOM, as the name suggests, is a sub-DOM tree for HTML elements. It is mainly used for encapsulation and restricting styles up to the web component only.

Shadow DOM

To create a shadow DOM, attach it to a host, in our case the custom element itself is a host to the shadow DOM. However, the shadow DOM can only be attached to a custom element or these built-in elements mentioned in the HTML spec.

You can access elements outside the shadow DOM from inside the shadow DOM.

class DemoWebC extends HTMLElement {
  constructor() {
    super()
  }

  connectedCallback() {
    const shadow = this.attachShadow({mode: "open"})

    const style = document.createElement("style")
    style.textContent = `p { color: blue; }`
    shadow.appendChild(style)

    const text = document.createElement("p")
    text.textContent = "hello"
    shadow.appendChild(text)
  }
}

customElements.define("demo-webc", DemoWebC)

Shadow DOM has two modes: open and closed. Open means external elements in the page can modify the contents of the shadow DOM by using shadowRoot property. In the closed mode, the shadow DOM is not accessible from outside using the shadowRoot property as it is null in this case.

Try doing this on a closed shadow DOM custom element:

console.log(document.querySelector("demo-webc").shadowRoot)

It returns null.

Templates and slots are extremely useful when building complex custom elements or web components. Diving deep into them is out of the scope of this article, but here are some good resources for them:

Styling Shadow DOM

The shadow DOM can be styled either by:

  • Constructing a CSSStyleSheet object, inserting CSS in it using replaceSync() and attaching it to the shadow DOM using the adoptedStyleSheets property.
const shadowDOM = this.attachShadow({mode: "open"})
const styleSheet = new CSSStyleSheet()
styleSheet.replaceSync(`p { color: blue; }`)
shadowDOM.adoptedStyleSheets = [styleSheet]
<template id="custom">
    <head>
        <style>p { color: blue; }</style>
    </head>
    <p>Web Component</p>
</template>
const shadowDOM = this.attachShadow({ mode: "open" })
const template = document.querySelector("#custom")
shadowDOM.appendChild(template.content.cloneNode(true))
  • Simply creating a style tag and inserting CSS as text in it.
const style = document.createElement("style")
style.textContent = `p { color: blue; }`
shadow.appendChild(style)

Creating Your Own Web Component #

The first web component shown below is a custom button element which opens a dialog element. And the second web component involves a shadow DOM to pretty print JSON string in HTML.

Similarly, you can create your own custom elements and use them anywhere you want.

See the pen (@seekertruth) on CodePen.


Advent Of Code 2023 - Day Two Solution

Previous

Advent Of Code 2023 - Day Four Solution

Next