Skip to content
AgileLogics
7 min read

Custom Elements

Prerequisites: The template element

Custom elements let you define your own HTML tags, backed by a JavaScript class. Together with shadow DOM and templates, they're called web components, and they work in every modern browser and with any framework.

<p>Price: <price-tag amount="4.2" currency="GBP"></price-tag></p>
<p>Price: <price-tag amount="12" currency="EUR"></price-tag></p>

<script>
  class PriceTag extends HTMLElement {
    static observedAttributes = ["amount", "currency"];

    connectedCallback() {
      this.render();
    }

    attributeChangedCallback(name, oldValue, newValue) {
      if (this.isConnected) this.render();
    }

    render() {
      const amount = Number(this.getAttribute("amount"));
      const currency = this.getAttribute("currency") || "USD";
      this.textContent = new Intl.NumberFormat("en-GB", { style: "currency", currency }).format(amount);
    }
  }

  customElements.define("price-tag", PriceTag);

  const tags = document.querySelectorAll("price-tag");
  console.log([...tags].map(t => t.textContent).join(" | "));
  tags[0].setAttribute("amount", "5");
  console.log(tags[0].textContent);
</script>
£4.20 | €12.00
£5.00

Rules for names

  • The name must contain a hyphen (price-tag, x-button) — so it can never clash with a future built-in element.
  • Lowercase, starting with a letter.
  • Custom elements always need an end tag: <price-tag></price-tag>, never <price-tag/>.

Lifecycle callbacks

Callback Called when
constructor() An instance is created — don't touch attributes or children here
connectedCallback() Added to the document (can happen more than once)
disconnectedCallback() Removed — clean up timers and listeners
attributeChangedCallback(name, old, new) An attribute listed in observedAttributes changes
adoptedCallback() Moved to another document (rare)

Before and after definition

An element that appears before its definition is an HTMLElement with no behavior. When customElements.define runs, it is upgraded. CSS can style the waiting state with :not(:defined) — handy for avoiding a flash of unstyled content. customElements.whenDefined("price-tag") returns a promise.

Accessibility is your job

A custom element has no role and no keyboard behavior. Either:

  • build from real elements inside it (a <button> inside <copy-button>), or
  • use ElementInternals (this.attachInternals()) to set a role and states, and to take part in forms — form-associated custom elements (static formAssociated = true) can have a value submitted with a form and take part in validation.

Progressive enhancement: HTML web components

A popular pattern wraps existing HTML and enhances it, so the page works before JavaScript loads:

<copy-code>
  <pre><code>npm install sass</code></pre>
</copy-code>
<script>
  customElements.define("copy-code", class extends HTMLElement {
    connectedCallback() {
      const button = document.createElement("button");
      button.type = "button";
      button.textContent = "Copy";
      button.addEventListener("click", () =>
        navigator.clipboard.writeText(this.querySelector("code").textContent));
      this.append(button);
    }
  });
  console.log(document.querySelector("copy-code button").textContent);
</script>
Copy

Without JavaScript, readers still see the code; with it, they also get a Copy button.

Related developer tools