< Back
calendar

Aug 31, 2020

How To Extend A Native HTML Element

When standard HTML is not enough

Header for posting How To Extend A Native HTML Element
Photo by Ryan Quintal on Unsplash

Ever since the days of XML we have tried to extend HTML with our own tags.

The standard library of HTML tags is fairly limited and intentionally consists of low-level building blocks, meant to be composed by developers into more high-level functionality.

Now that all modern browsers support Web Components (or more specifically Custom Elements) you can create your very own HTML elements that you can use anywhere by just loading a script and adding the tag to the document.

It’s really as simple as that.

If you have created your own image gallery, you can use it by just loading the script and adding <image-gallery></image-gallery> to the document:

class ImageGallery extends HTMLElement {
constructor() {
super();
} ...} customElements.define('image-gallery', ImageGallery); <image-gallery></image-gallery> // presto!

Here the ImageGallery class contains all the functionality for the <image-gallery> HTML element and we register it throughcustomElements.define with the 'image-gallery' tag name.

Now frameworks like React, Angular and Vue.js also allow you to create your own HTML tags, but contrary to framework components, Custom Elements are real first-class HTML elements.

In this case the ImageGallery class extends HTMLElement, which is the base interface of all HTML elements. This means that it will inherit all the functionality that is common to all HTML elements.

For example, you can attach event listeners to it through addEventListener, use CSS to style it through its style property or interact with it in the browser devtools like any other HTML element.

And it doesn’t stop there.

Standing On The Shoulders Of Giants

Instead of extending HTMLElement, Custom Elements can also extend other built-in HTML elements like <button>, <img> and <a> for example.

Let’s say we want to create a lazy loading image that will not load until it’s scrolled into the viewport. We could do this by searching for all images in the page and attach a IntersectionObserver to each image that makes sure the image will only load when it becomes visible.

But we could also extend the built-in image element itself and use that enhanced image element instead of the regular <img> HTML element.

We can do this by creating a Custom Element that doesn’t extend HTMLElement but instead extends the interface of the <img> element, which is HTMLImageElement:

class LazyImg extends HTMLImageElement {
constructor() {
super();
} ... } customElements.define('lazy-img', LazyImg, {extends: 'img'});

The Custom Element is registered with the usual call to customElement.define but now it takes a third argument, {extends: 'img'}, that specifies which HTML element will be extended.

Now instead of using a new HTML tag, we can just use our enhanced image element with the regular <img> tag but we add the new functionality to it through the is attribute:

<img is="lazy-img" src="/path/to/image.jpg">

This image is now an enhanced image that gets all the functionality we defined in the LazyImg class.

The complete implementation of LazyImg is too large for this article but you can find the source code on my Github.

The beauty of this approach is that any browser that doesn’t support extending built-in HTML elements will simply ignore the is attribute and just render a regular image.

Progressive enhancement at its finest.

Example: client-side routing

This way, we can also easily enhance ordinary links to become links that work with a client-side router.

Normally, we would need to loop through all these links and write some code to prevent that we navigate to another page when the link is clicked, because we want to handle the routing on the client-side.

By extending the native <a> tag, we can simply add an is attribute to indicate it is a client-side link, so it won’t make the browser go to the page specified in its href attribute when clicked.

We do this by extending the HTMLAnchorElement which is the interface for the <a> tag:

class RouterLink extends HTMLAnchorElement {
constructor() {
super();
}

connectedCallback() {
this.addEventListener('click', e => {
e.preventDefault();

this.dispatchEvent(new CustomEvent('route-change', {
composed: true,
detail: {link: this}
}));
})
}
}

In the connectedCallback we set an event handler to intercept the click event. By calling e.preventDefault, we prevent the browser from following the link so nothing happens when a user clicks the link.

Then we throw a new route-change event with the link as the payload in the link property. A parent element can listen for this event and perform the client-side routing, for example a nav tag that has also been extended:

<nav is="client-side-router">
<a href="/path/to/page1" is="router-link">Page 1</a>
<a href="/path/to/page2" is="router-link">Page 2</a>
<a href="/path/to/page3" is="router-link">Page 3</a>
</nav>

This way, we can build a navigation component that will work perfectly fine in older, not supporting browsers and that will be enhanced to a client-side router in modern browsers.

Let’s look at how we could implement the router itself by extending the <nav> tag.

The <nav> tag doesn’t have its own interface so it simply extends HTMLElement. Although it is a built-in element we can still add Shadow DOM to it which will make interacting with the child element, the links, a bit easier and robust:

class ClientSideRouter extends HTMLElement {
constructor() {
super();

const shadowRoot = this.attachShadow({mode: 'open'});

shadowRoot.innerHTML = `
<slot name="link"></slot>
`;
}

connectedCallback() {
const slot = this.shadowRoot.querySelector('slot');
const links = slot.assignedNodes();

links.forEach(link => {
link.addEventListener('route-change', e => {
this.handleRouteChange(e.detail.link);
});
});
}
}

The Shadow DOM of the router will only contain a named <slot> element which will serve as the insertion point for the links. We can then get all links through the assignedNodes method of the <slot> and add an event listener to each link, so we can handle the route change when a link is clicked.

The only thing we need to add on the links is a slot attribute to make sure they are inserted in the correct slot:

<a href="/path/to/page1" is="router-link" slot="link">Page 1</a>

We could omit the name attribute on the slot and the slot attribute on the link. That would also work but then any newlines inside the nav components would also be returned by assignedNodes() as empty text nodes and we would need to filter them out.

This is nice, but the fact that we need to add a separate event handler to each link is a bit unfortunate and inefficient. It would be better if we could just add a single event handler to the router itself.

We can do this by adding bubbles: true to the config object of the route-change event thrown by RouterLink:

this.dispatchEvent(new CustomEvent('route-change', {
composed: true,
bubbles: true, // <-- add this to make the event bubble up
detail: {url}
}));

The event will now bubble up and we can listen to it on the router itself:

class ClientSideRouter extends HTMLElement {
constructor() {
super();

const shadowRoot = this.attachShadow({mode: 'open'});

shadowRoot.innerHTML = `
<slot name="link"></slot>
`;
}

connectedCallback() {
this.outlet = document.querySelector(this.dataset.outlet);

this.addEventListener('route-change', e => {
this.handleRouteChange(e.detail.link)
});

}

handleRouteChange(link) {
// handle route change
}
}

customElements.define('client-side-router', ClientSideRouter, {extends: 'nav'});

We could now also make a simple implementation of the handleRouteChange method. We could add a data- attribute to the router, containing a CSS selector to specify where the templates should be rendered and a data- attribute to each router link to specify which template should be rendered:

<nav is="client-side-router" data-outlet="#main">
<a href="/path/to/page1" is="router-link" slot="link"
data-template="./page1.html">Page 1</a>

... </nav> <!-- templates are rendered here -->
<div id="main"></div>

In the handleRouteChange method we then fetch the template, render it inside the outlet and add and entry to the browser’s history so the url will change to reflect the route change:

async handleRouteChange(link) {
const template = link.dataset.template;
const url = link.getAttribute('href');
const state = {template, url};

const html = await (await fetch(template)).text();

history.pushState(state, null, url);

this.outlet.innerHTML = html;
}

Now this is obviously a naive and very basic implementation, but I hope you got an idea of what is possible by extending built-in HTML elements.

You can find the source code including a demo page on my Github.


Join Modern Web Weekly, my weekly update on the modern web platform, web components, and Progressive Web Apps delivered straight to your inbox.