min read

Defining custom elements in HTML

A while ago I tweeted a thread about how a small JavaScript snippet, one that can fit in a single tweet in fact, can be used to allow defining custom elements purely in HTML. This post will expand on the idea, show how the snippet works, and argue for why you might want to actually use this.

Creating an element looks like this:

<decorate-element tag="pricing-tier">
  <template>
    <style>
      :host { display: block; }
    </style>
    
    <header>
      <h1>$<slot name="price"></slot></h1>
      <h2><slot name="name"></slot></h2>
    </header>

    <div class="features">
      <slot name="features"></slot>
    </div>
  </template>
<decorate-element>

Which then allows you to use this element like so:

<pricing-tier>
  <span slot="price">10</span>
  <span slot="name">Basic</span>
  
  <ul slot="features">
    <li>Unlimited foo</li>
  </ul>
</pricing-tier>

As you can see, this allows us to define a custom element tag, in this case pricing-tier and a template. Slots provide the basic mechanism to pass information into the template for rendering.

The snippet for <decorate-element> is available here.

How this works

It's really quite simple. <decorate-element> is itself a custom element, one that expects a <template> element as its only child.

Here's its definition:

customElements.define('decorate-element', class extends HTMLElement {
  connectedCallback() {
    let tag = this.getAttribute('tag');
    let template = this.firstElementChild;
    if(template) {
      decorate(tag, template);
    } else {
      let mo = new MutationObserver(() => {
        template = this.firstElementChild;
        if(template) {
          mo.disconnect();
          decorate(tag, template);
        }
      });
      mo.observe(this, { childList: true });
    }
  }
});

Let's walk through what this does:

And here's decorate:

function decorate(tag, template) {
  customElements.define(tag, class extends HTMLElement {
    constructor() {
      super();
      this.attachShadow({ mode: 'open' });
    }
    
    connectedCallback() {
      let root = this.shadowRoot;
      if(!root.firstChild) {
        root.appendChild(document.importNode(template.content, true));
      }
    }
  });
}

This just receives that tag name and dynamically defines a custom element. In connectedCallback we clone and append the template. That's it!

Why use this?

To experienced developers this might just seem like a less powerful way to create custom elements. That's not really what I have in mind, though. Think about the amount of repetitive HTML is written in the typical HTML page. Even if you try really hard to write semantically and not include superfluous wrapper elements you'll still wind up with many anyways for things like layout.

This is where <decorate-element> works like a charm. You can express content in a semantic fashion and tuck away the structural stuff inside of a custom element.

More important, you can actually reduce the size of your pages by eliminating the repetitive code. And because this snippet is so small, it can easily be inlined into the page. Doing this prevents any flash of unstyled content because the elements are defined as part of HTML parsing and will be "upgraded" before they are ever painted to the screen. Web components for the win.