How To Create Reusable HTML Components Using Only JavaScript

A modern way of creating Single Page Applications without any frontend framework

·

12 min read

How To Create Reusable HTML Components Using Only JavaScript

With the advent of a plethora of frontend frameworks and libraries, many developers are gradually losing touch with a lot of the amazing features that are baked into JavaScript. While these frameworks and libraries simplify a lot of the development process, in some cases, using them can be overkill.

One of the major challenges of using HTML is the unnecessary repetitiveness it introduces to the codebase. However, JavaScript provides several features that provide a workaround for this repetitiveness. One of these features is the newly introduced customElements object. In this article, we dive into what custom elements are, how to create them using customElements, how to create more flexible and reusable custom components using the slot attribute, styling and adding event listeners to a custom element, and the advantages and drawbacks of using custom elements.

An Introduction to Web Components

If you are familiar with React, Vue, or any similar frontend framework, you have probably come across, or even worked with components that use a syntax similar to this:

<MyComponent />

These components provide a way to encapsulate and split your code into different sections or files based on their functionality, making it very easy to write code once and reuse it as many times as necessary. But what if I told you that there is a way to achieve something very similar to this using just HTML and vanilla JavaScript? Custom elements are a part of the newly introduced Web Components API. They provide a way to define and use custom HTML tags with custom functionalities, and reuse those tags/elements as many times as you want.

Custom Elements

Custom elements incorporate three different features that are intertwined between HTML and JavaScript. These are:

  • HTML templates

  • Shadow DOM

  • CustomElements (an instance of CustomElementRegistry)

Templates

The HTML template tag is used for creating an HTML markup that is not displayed immediately but is rather intended to be rendered later using JavaScript.

<template>
    <section>
        <h2>HTML Templates</h2>
        <p>everything in a template tag is not displayed on the browser.</p>
    </section>
</template>

We can use JavaScript to display the contents of this template in the browser by cloning its content and appending it to an element in the DOM.

const template = document.querySelector('template')
const clone = template.content.cloneNode(true)

document.querySelector('main').appendChild(template)

In the example above, we first select the content of the template, then clone its content by referencing content.cloneNode(), the Boolean flag (true) is to specify that it should be a deep copy.

Shadow DOM

If you've been using React for a while, or know how it works, you might have heard about the Shadow DOM. This works just like the regular DOM, in the sense that it represents a tree-like structure of the relationship between HTML elements in the browser. The unique feature of the shadow DOM is that it allows hidden DOM trees to be attached to elements in the regular DOM tree.

// create a div element
const htmlSection = document.createElement('div')
// create HTML using template literals
const content = `
<h3>This is a heading</h3>
<p>this content is meant to be added to the shadow DOM</p>
`
htmlSection.innerHTML = content
// select the element with a class called root
const root = document.querySelector('.root')
// attach a shadow DOM to the selected element
root.attachShadow({ mode: 'open' })
// append the html element to the shadow DOM
root.shadowRoot.appendChild(htmlSection)

CustomElement

customElementsRegistry or simply customElements is a JavaScript object that allows you to define a customized HTML element/tag, along with its content and functionality, and use the element in your HTML as a regular HTML element. This element would usually be a block of code that can be used multiple times in your HTML.

The syntax for creating a custom element is as follows:

customElements.define('element-name', customClass )

In the code snippet above,

  • element-name represents the name of the custom element. This name can be anything, as long as it follows the naming convention, which we will talk about soon.

  • customClass represents a class object which usually extends the HTMLElement class, and also contains the functionality and content of the custom element.

Now let's dive into some examples of how to create and use custom elements.

Basic usage

The simplest way to create a custom element is as follows:

class anyName extends HTMLElement {}
customElements.define('custom-element', anyName)

In this example, we created a JavaScript class called anyName which inherits HTMLElement object, then we used customElements to define a custom element, custom-element which is the name we have chosen to represent our custom tag, and then passed the class as the second argument.

After defining the custom element, you can then use it in your HTML as follows:

<custom-element>Some text here</custom-element>

Practical example

The previous example is probably not that useful, as you can achieve the same functionality—maybe even more—by using any native HTML tag. However, custom elements are more useful for creating reusable components, like navbars, footers, etc. Here's an example of a navbar component:

// create an HTML template element
const template = document.createElement('template')

template.innerHTML = `
<nav>
    <div>
        <img src="./assets/logo.png" alt="logo">
    </div>
    <ul>
        <li><a href="/">Home</a></li>
        <li><a href="/about">About</a></li>
        <li><a href="/services">Services</a></li>
        <li><a href="/contact">Contact</a></li>
    </ul>
</nav>
`
// create a navBar class, and clone the content of the template into it
class navBar extends HTMLElement {
    constructor() {
        super()
        this.attachShadow({ mode: 'open' })
        this.shadowRoot.appendChild(template.content.cloneNode(true))
    }
}
// define a custom element called 'nav-bar' using the navBar class
customElements.define('nav-bar', navBar)

The above code creates a custom navbar component that we can use in our HTML document by just adding <nav-bar></nav-bar> to the markup.

Extending functionality with slots

Oftentimes, when working with components, you might want to be able to add extra content or functionality that is unique to the current page or section you are using the component. A good instance of this is when creating modals or popups. Modals usually have a basic layout and structure, the difference is usually in the content. By using a slot in custom elements, you can create a component that is able to accept extra or unique content.

Let's extend the functionality of our navbar component by adding a section that displays a login button if the user is not logged in, but displays a logout button if the user is logged in:

// adding slots to a custom element
template.innerHTML = `
<nav>
    <div>
        <img src="./assets/logo.png" alt="logo">
    </div>
    <ul>
        <li><a href="/">Home</a></li>
        <li><a href="/about">About</a></li>
        <li><a href="/services">Services</a></li>
        <li><a href="/contact">Contact</a></li>
    </ul>

    <slot name="auth">
        <button>Login</button>
    </slot
</nav>
`

By default, when we add the custom component to our markup, it shows the login button. However, we can replace this slot element with any HTML content of our choice (in this case, a logout button):

// using slots to replace content defined in a custom element
<nav-bar>
    <div slot="auth">
        <button>Logout</button>
    </div>
</nav-bar>

Some things to keep in mind when using slots:

  • You can add as many slots as you want in your custom components, and reference them in your markup.

  • Slots work like IDs in HTML markup; each slot name within a component must be unique.

  • The content being used to replace the slot can also be anything, as long as the name of the slot corresponds.

  • Slots have a default display value of contents which can make them appear like inline elements by default, even when replaced with block elements. If you want the slot to display as a block element, you have to style it as such (using display: block; or display: inline-block;).

Styling a custom element

Styling a custom element works a bit differently from styling a regular HTML document, as it does not support external CSS. The reason for this behavior is primarily because the shadow DOM encapsulates and separates the content within it from the main DOM.

The only way to style a custom element is by writing the stylesheet within the template; this can be via a style tag, Bootstrap, inline styles, or any method that involves writing the styles directly in the template.

// Styling a custom element
template.innerHTML = `
<style>
.navbar {
    height: 4rem;
    width: 100%;
    display: flex;
    align-items: center;
    padding: 1rem 2rem;
}

.navbar__logo {
    height: 100%;
}

.brand {
    height: 100%;
    object-fit: contain;
}

.navlink__container {
    display: flex;
    align-items: center;
    gap: 2rem;
    margin-left: auto;
}

.navlink {
    list-style: none;
    font-size: 1.6rem;
}

.navlink a {
    text-decoration: none;
    color: inherit;
}
</style>
<nav class="navbar">
    <div class="navbar__logo">
        <img src="./assets/logo.png" class="brand" alt="logo">
    </div>
    <ul class="navlink__container">
        <li class="navlink"><a href="/">Home</a></li>
        <li class="navlink"><a href="/about">About</a></li>
        <li class="navlink"><a href="/services">Services</a></li>
        <li class="navlink"><a href="/contact">Contact</a></li>
    </ul>

    <slot name="auth">
        <button class="primary-btn">Login</button>
    </slot
</nav>
`

However, slots support external styles, for example, we can style the auth slot via a CSS file:

<!-- styling a slot -->
<nav-bar>
    <div slot="auth" class="auth__wrapper">
        <button>Logout</button>
    </div>
</nav-bar>

Adding Event Handlers to a Custom Element

Adding event listeners to a custom element also follows a similar pattern with adding styles as we discussed earlier: all event listeners must be defined as a method within the class.

You can add event listeners or manipulate child elements within the custom element by defining the event handler in a connectedCallback method,

// create a navBar class, and clone the content of the template into it
class navBar extends HTMLElement {
    constructor() {
        super()
        this.attachShadow({ mode: 'open' })
        this.shadowRoot.appendChild(template.content.cloneNode(true))
    }

// adding event listeners to child elements within a custom element
    connectedCallback() {
        this.shadowRoot.querySelector('.primary-btn').addEventListener('click', (e) => {
            console.log('shadow event triggered!')
        })
    }
}

In the above example, we defined a connectedCallback method in the navBar class. We then proceeded to add an event listener to the login button; therefore when the button is clicked, we see shadow event triggered! in the console.

Some Interesting Gotchas About Custom Elements

While testing different ways to work with custom elements, I found a couple of things I believe you will find interesting. Here are some of them:

  1. You can append a custom element using JavaScript via this syntax:

     container.innerHTML = `<custom-element></custom-element>`
    
    1. You can also define and use custom elements without using the shadow DOM:
const content = `
<h3>This is a heading</h3>
<p>Hello world from here</p>
<p>this content is meant to be added to the Custom Element</p>
<button>Hello There</button>
`

class element extends HTMLElement {
    constructor() {
        super()
        this.innerHTML = content
    }
}

The benefit of using this method is that the component is treated as part of the main DOM tree, therefore:

  • it can be styled using a separate CSS file

  • Event listeners for child elements can be defined outside the class

  • You can either add the event listener in the connectedCallback or define it outside the class.

    The drawback of this method is that slots no longer work, which means that you can no longer add modifiable content within the custom component.

  1. Slots can have event listeners both in the connectedCallback method and outside the class object, but only if the slot is used in the markup. this is because a slot and its replacement are treated as separate elements. When you add event listeners like this, the one defined outside the class is triggered first.

Rules For Naming Custom Elements

When creating custom elements, there are certain naming conventions/rules that must be followed, otherwise, it would result in an error:

  • It must contain a hyphen

  • It must not contain any ASCII uppercase letters

  • It must start with an alphabet

  • It cannot be any of these reserved words:

    annotation-xml,
    color-profile font-face
    font-face-src, font-face-uri, font-face-format, font-face-name, missing-glyph

  • Asides from lowercase alphabets and hyphens, a name can also include a dot ("."), underscore ("_"), and a few other special characters.

Advantages Of Using Custom Elements

So far, we've discussed quite a lot about custom elements; now let's talk about some advantages of using them. There are several advantages of using custom elements, below are some of them:

  • Reusability: Custom elements promote code reusability by encapsulating specific functionalities into self-contained components. Once defined, they can easily be used throughout the application or even across different projects, leading to more efficient development and maintenance.

  • Framework Agnostic: They work natively in modern browsers, making them independent of any specific JavaScript framework or library. This flexibility makes it easy to use custom elements alongside other frameworks or even without any framework, depending on project requirements.

  • Vendor Independence: Using custom elements avoids the vendor lock-in often associated with using specific frameworks. This independence ensures that the application remains viable even if the preferred framework becomes less popular or maintained in the future.

  • Forward Compatibility: As custom elements follow web standards, they are designed to be future-proof. Browsers will continue to support them and adhere to the specifications, reducing the risk of breaking changes in the future.

  • Modularity: This is usually one of the major reasons a lot of companies/individuals use component-based frameworks. By breaking down complex user interfaces into smaller custom elements, developers can create a modular architecture. This approach simplifies the development process, as each element can be developed and tested independently before being combined into larger components or applications.

Disadvantages And Drawbacks Of Custom Elements

Along with their many advantages, custom elements also have some disadvantages and drawbacks which you should consider before using them:

  • Lack of Built-in Features: Unlike some frameworks, custom elements do not come with built-in features and utilities like state management, routing, or form validation. You may need to implement or integrate these functionalities separately, which can be more time-consuming.

  • Event Handling Complexities: Managing event listeners and propagating events among custom elements can be more complex than in some frameworks, where event handling is abstracted and streamlined.

  • Browser Support: While modern browsers have good support for custom elements, some older browsers may lack full support or require polyfills to function correctly. This can add complexity to the development process, especially when considering the need for backward compatibility.

Conclusion

This article was meant to give you a basic overview of what is possible with just HTML and vanilla JavaScript. Custom elements might just be a step in the direction of framework-agnostic, component-based frontend development. We might even be able to create Single Page Applications with just HTML and vanilla JavaScript! However, for developers to fully adopt it, a lot of improvement is required to standardize, and make it more developer-friendly.

You can read more technical details about custom elements here.

Also read more about the Web Components API here

Did you find this article valuable?

Support Paul Saje by becoming a sponsor. Any amount is appreciated!