Mayank RSS

Different ways of defining custom elements

I’ve developed a habit of defaulting to writing “wrapper” elements any time I need some dynamic behavior on any element. If I need a click handler on a button, I will do this 9/10 times:

Code snippet
<my-butt>
  <button>hello</button>
</my-butt>

What’s interesting is the JS part that comes after this. For me, it’s almost always an inline anonymous class:

Code snippet
customElements.define("my-butt", class extends HTMLElement {});

That line is quite long, but it skips a step and avoids the need to name the class. Here’s the “regular” version:

Code snippet
class MyButt extends HTMLElement {}
customElements.define("my-butt", MyButt);

Another equivalent approach would be to use a static block, which I think reads nicer.

Code snippet
class MyButt extends HTMLElement {
  static {
    customElements.define("my-butt", this);
  }
}

This works perfectly for one-off custom elements, but it’s not very flexible and the consumer has no control over it. For reusable custom elements, it’s better to expose a static method that can be manually called (e.g. MyButt.register()).

Code snippet
class MyButt extends HTMLElement {
  static register() {
    customElements.define("my-butt", this);
  }
}

However, there is still no way to customize the “tag name”. What if my-butt is already occupied? A reusable element needs to allow registering itself with a different tag name.

We can allow passing the tag’s name as an optional parameter in our static register method.

Code snippet
class MyButt extends HTMLElement {
  static register(tagName = "my-butt") {
    customElements.define(tagName, this);
  }
}

Then call it like this:

Code snippet
MyButt.register("my-button");

A slightly different approach would be to use a static property. We can name it tagName to match the read-only tagName property of HTMLElement.

Code snippet
class MyButt extends HTMLElement {
  static tagName = "my-butt";

  static register() {
    customElements.define(this.tagName, this);
  }
}

Then it can be changed before registering the custom element.

Code snippet
MyButt.tagName = "my-button";
MyButt.register();
Code snippet
<my-button>
  <button>hello</button>
</my-button>
Differences between static and instance properties

One interesting difference is that the instance tagName property is always returned as uppercase.

Code snippet
static init() {
  console.log(this.tagName); // my-button
}

connectedCallback() {
  console.log(this.tagName); // MY-BUTTON
}

It would be nice if we could match the static property to also be uppercase. Sadly, we cannot define a custom element with an uppercase name.

Code snippet
// ❌ not allowed
customElements.define("MY-BUTTON", MyButt);

We can however convert the string to lowercase before defining the custom element!

Code snippet
class MyButt extends HTMLElement {
  static tagName = "MY-BUTT";

  static register() {
    console.log(this.tagName); // MY-BUTT
    customElements.define(this.tagName.toLowerCase(), this);
  }

  connectedCallback() {
    console.log(this.tagName); // MY-BUTT
  }
}

Is this worth it? I’m not sure.

The benefit of the static property starts to become more evident when we use a shared base class across multiple elements. The register method stays the same between sub-classes, so only the property needs to be set.

Combining it all:

Code snippet
class Base extends HTMLElement {
  static register(tagName = this.tagName) {
    customElements.define(tagName, this);
  }
}

class MyButt extends Base {
  static tagName = "my-butt";
}

class MyToggle extends Base {
  static tagName = "my-toggle";
}

class MyThing extends Base {
  static tagName = "my-thing";
}

If all our custom elements are coded this way, we can even register them all at the same time in a loop.

Code snippet
[MyButt, MyToggle, MyThing].forEach((element) => element.register());