Posted on ::

The HTML spec has had a proper way to invent your own tags since 2019. Most web developers seem to have missed it.

Want a simple way to have a word in blue? just use <ink-blue>word</ink-blue>

My migration of this site to Zola Tera 2 forced me to rethink shortcodes and ad-hoc styling, which is what prompted me to dig into ยง4.13 of the HTML standard for the first time. I found three pieces, all now supported by every current engine.

What?๐Ÿ”—

Custom elements are an old idea โ€” Chrome shipped a v0 API in 2014, the cross-browser v1 standard followed a few years later. The pattern is straightforward: extend HTMLElement, register the class via customElements.define(). The simplest example, lifted from the spec, is a country-flag icon:

class FlagIcon extends HTMLElement {
  constructor() {
    super();
    this._countryCode = null;
  }
  static observedAttributes = ["country"];

  attributeChangedCallback(name, oldValue, newValue) {
    this._countryCode = newValue;
    this._updateRendering();
  }
  connectedCallback() {
    this._updateRendering();
  }
  get country() { return this._countryCode; }
  set country(v) { this.setAttribute("country", v); }
}
customElements.define("flag-icon", FlagIcon);

And use it as:

<flag-icon country="nl"></flag-icon>

The only rule I had to learn the hard way: the name must contain a hyphen. <flag-icon> works; <flagicon> does not. The hyphen is the spec's way of ensuring your tag can never collide with a future native one.

Three lifecycle hooks do most of the heavy lifting โ€” connectedCallback, disconnectedCallback, attributeChangedCallback. There is also adoptedCallback (when your element moves documents) and the newer connectedMoveCallback (added so the element keeps its state when moved inside the DOM without being disconnected). That is all there is to it.

ElementInternals๐Ÿ”—

This is the piece that surprised me the most. Set one static field and call one method, and your custom element behaves like a real form control โ€” it appears in form.elements, submits values, has validity, willValidate, and labels. It also carries default accessibility semantics, so screen readers treat your tag like the proper role without you attaching ARIA manually:

class MyCheckbox extends HTMLElement {
  static formAssociated = true;
  static observedAttributes = ["checked"];

  constructor() {
    super();
    this._internals = this.attachInternals();
    this._internals.role = "checkbox";
    this._internals.ariaChecked = "false";
    this.addEventListener("click", this._onClick.bind(this));
  }

  attributeChangedCallback(name, oldValue, newValue) {
    this._internals.setFormValue(this.checked ? "on" : null);
    this._internals.ariaChecked = this.checked;
  }

  get checked() { return this.hasAttribute("checked"); }
  set checked(flag) { this.toggleAttribute("checked", Boolean(flag)); }
  _onClick() { this.checked = !this.checked; }
}
customElements.define("my-checkbox", MyCheckbox);

A nice extra: this._internals.states.add("checked") plus the CSS :state(checked) pseudo-class lets you flip states from JavaScript and style them with CSS, no attribute-flipping dance.

ElementInternals has been Baseline widely available since March 2023.

Declarative Shadow DOM๐Ÿ”—

The piece I missed most. <template shadowrootmode="open"> inside your custom element lets the shadow tree be rendered from pure HTML โ€” no JavaScript required for the initial render:

<my-component>
  <template shadowrootmode="open">
    <style>p { color: tomato }</style>
    <p>Hello from a shadow root</p>
  </template>
</my-component>

For static sites this is a game changer โ€” Zola, Hugo, plain Markdown โ€” your shadow tree ships as part of the HTML, the browser wires it up at parse time. No flash of unstyled component, no JS bundle needed.

When upgrading from HTML that contains a declarative shadow root, check ElementInternals.shadowRoot first โ€” if it is already populated, your markup already shipped the tree and you should not call attachShadow() again.

Browser support landed between March 2023 and February 2024 โ€” Chrome 111+, Safari 16.4+, Firefox 123+. There is also shadowrootclonable and shadowrootdelegatesfocus for cloning and focus delegation. See the Declarative Shadow DOM guide on web.dev for the details.

Closing๐Ÿ”—

Three pieces, all in the HTML spec, all shipped in every current browser. The only thing missing was awareness. So, awareness raised โ€” we no longer have any excuse to keep reinventing component frameworks. :-)

Table of Contents