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.