Niza Toshpulatov

October 15, 2023

Easing Pains Of Working With Web Components (Custom Elements)

So I do web development. Lots of it. Most of it is React and Node.js as you might expect. But when it comes to my personal website, I always do it vanilla. No libraries, no frameworks, no nothing. Just the good old HTML/CSS/JS stack. It's like a fun challenge I give myself every year or so. Try to build a scalable good looking website without any external help!

In the old days, I would just avoid JS altogether or at least minimize it to some carousel or navigation related code. I would try to steer away from JS as much as possible, and use CSS for all the fancy stuff. But in the past years, built-in browser features have come a long way.

JavaScript is no longer mocked as a toy language thanks to ECMA standards that are bringing powerful features to the language every year. CSS is deprecating hundreds of JS libraries annually with features like nesting, layers, container queries and animation-timeline. And HTML now has built-in support for modals with the <dialog>Β element and accordions with the <details>Β element.

But aside from language-centric improvements, we now also have web components, web's native way of creating reusable blocks. Our very own custom elements!

Unfortunately, it's a bit pain to work with web components outside of frameworks. There is a great guide on MDN describing the state and the application of web components. Reading it, you will learn that you have to work with 2 primary concepts:

  • Custom elements
  • Templates (and slots)

To define a custom element in the DOM, you just need to call the define function on the global customElements property:

customElements.define("my-footer", MyFooter)

This is a very simple and nice API, however the pain comes when you dig into the custom element class that you need to provide as the second argument.

Once again, it starts very simple. Create a class that extends an HTML element:

class MyFooter extends HTMLElement {
  constructor() {
    super();
    // write some custom code here...
  }
}

Isn't this great?! Just create a class and implement any logic you want! But wait...

"Where or how do I define my markup and styles?"

Don't worry, there are 2 ways:

  1. Define a template literal string with all of your markup and styles and store it as a constant.
  2. Define a <template> element somewhere on your page and insert it in your custom element's constructor.

So if you were to go with the option 1, it could look something like this:

// src/main.js

const template = `
<template>
  <style>
    footer {
      display: grid;
      place-content: center;
      text-align: center;
      padding: 1.5rem;
      font-size: 0.875rem;
      opacity: 0.75;
    }
  </style>
  <footer>
    <p>
      For additional inquiries contact me via email
      <a href="mailto:niza@hey.com">niza@hey.com</a>.
    </p>
    <p>Niza Toshpulatov Β© <span id="current-year"></span></p>
  </footer>
</template>
`;

class MyFooter extends HTMLElement {
  constructor() {
    super();
    
    const shadowRoot = this.shadowRoot.attach({ mode: "open" });
    
    const templateDocument = new DOMParser().parseFromString(
      template,
     "text/html",
    );

    const templates = templateDocument.getElementsByTagName("template");

    if (templates.length === 0) {
      throw Error(`Failed to find the template.`);
    }

    const templateContent = templates[0].content;

    shadowRoot.appendChild(templateContent.cloneNode(true));

    const currentYearSpan = shadowRoot.querySelector("#current-year");

    if (currentYearSpan) {
      currentYearSpan.textContent = new Date().getFullYear().toString();
    }
  }
}

customElements.define("my-footer", MyFooter);

This works, but there are several downsides:

  • We don't get any help from our editor when working with that template constant. No syntax highlighting, no autocompletions.
  • While keeping markup and JS in the same file is basically an industry standard nowadays, I personally don't like it. I like to keep things simple and separated.

So let's explore the option number 2. For this, we will have to define the template somewhere on our page and then query it in the JavaScript. Assuming you have an index.html file, we can put our template there:

<!doctype html>
<html lang="en">
  <head>
    <script src="src/main.js" type="module"></script>
    <title>My page</title>
  </head>
  <body>
    <main>
      <h1>Welcome to my page!</h1>
    </main>
    <my-footer></my-footer>
  </body>
  <template id="footer-template">
    <style>
      footer {
        display: grid;
        place-content: center;
        text-align: center;
        padding: 1.5rem;
        font-size: 0.875rem;
        opacity: 0.75;
      }
    </style>
    <footer>
      <p>
        For additional inquiries contact me via email
        <a href="mailto:niza@hey.com">niza@hey.com</a>.
      </p>
      <p>Niza Toshpulatov Β© <span id="current-year"></span></p>
    </footer>
  </template>
</html>

Then, we can query it in our JavaScript file:

// src/main.js

class MyFooter extends HTMLElement {
  constructor() {
    super();
    
    const shadowRoot = this.shadowRoot.attach({ mode: "open" });
    
    const template = document.querySelector("#footer-template");

    if (!template) {
      throw Error(`Failed to find the template.`);
    }

    const templateContent = template.content;

    shadowRoot.appendChild(templateContent.cloneNode(true));

    const currentYearSpan = shadowRoot.querySelector("#current-year");

    if (currentYearSpan) {
      currentYearSpan.textContent = new Date().getFullYear().toString();
    }
  }
}

customElements.define("my-footer", MyFooter);

This works. And I personally find this approach to be cleaner. But there are still some problems:

  • This solution isn't particularly scalable. If you want to use my-footer element on some other HTML file, you will have to copy and paste that template element or use the solution 1, but it isn't ideal either.

  • You can't access global styles, i.e. you cannot reference custom properties defined in your global styles. Everything within a custom element is scoped to its shadow root (aka shadow DOM, what an edgy name!).

    If you want to use custom properties and styles from your global CSS, you will have to adopt it manually:

shadowRoot.adoptedStyleSheets.push(baseStyles);
// how tf do I import the CSS though?

  • While using a class is straightforward, it produces a lot of boilerplate code:
    • class,
    • constructor,
    • super...

  • Did I mention that using the word "class" gives me conniptions?

So how do we resolve all of these issues? By inventing our own garbage JS framework! 🌠

Before you leave, it's not really a framework. It's a library (React folk rolling their eyes).

Remember when I told you that I don't use any dependencies when building my personal websites? Well I lied, because I used my own library this time.

It's called nz. You can find it on GitHub and NPM. It basically reduces the boilerplate code that you need to write when working with web components.

To demonstrate, let's take that my-footer example again. In nz, we would create a folder for our custom element:

/
└── public/
    └── src/
        └── components/
            └── my-footer/
                β”œβ”€β”€ my-footer.html
                └── my-footer.js

In the my-footer.html file we would define the template:

<template>
  <style>
    footer {
      display: grid;
      place-content: center;
      text-align: center;
      padding: 1.5rem;
      font-size: 0.875rem;
      opacity: 0.75;
    }
  </style>
  <footer>
    <p>
      For additional inquiries contact me via email
      <a href="mailto:niza@hey.com">niza@hey.com</a>.
    </p>
    <p>Niza Toshpulatov Β© <span id="current-year"></span></p>
  </footer>
</template>

In the my-footer.js file we would export a custom element defintion:

export const definition = {
  name: "my-footer",
  moduleUrl: import.meta.url,
  init({ shadowRoot }) {
    const currentYearSpan = shadowRoot.querySelector("#current-year");

    if (currentYearSpan) {
      currentYearSpan.textContent = new Date().getFullYear().toString();
    }
  },
};

Then, we would register it in our entry file using a bootstrap function:

import { bootstrap } from "https://cdn.jsdelivr.net/npm/nzjs/lib/core/bootstrap.js";
import { definition as footer } from "./components/my-footer/my-footer.js";

await bootstrap([footer]);

And that's it! Now we can just use the element on the page and it will just work.

Oh, and remember that problem that we had to manually adopt global stylesheets? nz handles that for you as well, just pass the styles as the second argument to the bootstrap function:

import { bootstrap } from "https://cdn.jsdelivr.net/npm/nzjs/lib/core/bootstrap.js";
import { definition as footer } from "./components/my-footer/my-footer.js";
import baseStyles from "./css/base.css" assert { type: "css" };

await bootstrap([footer], [baseStyles]);

Be careful though, import assertions are not supported in all browsers yet. As a workaround, nz will pick up all styles defined in your bootstrap.html that you can place in templates folder:

/
└── public/
    └── templates/
        β”œβ”€β”€ bootstrap.html
        └── loading.html

Check out the repo if you want to learn more.

I will work on nz on my free time, or more specifically:

I will work on nz when I'm working on my personal website...

Which by the way, is available at niza.cz.

Anyways, I just wanted to shared this short journey I took when trying to make web components a bit more pleasant to work with without introducing any external frameworks or libraries (except for nz of course).

I hope you enjoyed this more technically involved post. Feel free to open a GitHub issue or discussion if you will have some ideas or find a bug in nz.

Niza ✌️

About Niza Toshpulatov

Hi! I'm Niza, a web developer, productivity enthusiast, and a tech nerd. I love tinkering with software and in my free time, I like to compose music and write silly poems. Check out my website if you want to learn more about my work.