Custom elements
HTML ships with a fixed vocabulary of tags. It is a rich vocabulary, but a finite one. There is no <easy-tabs>, no <sliding-carousel>, no <beautiful-upload>. Pick almost any widget you have built by hand and you will find no tag for it.
Custom elements let you add those tags yourself. You describe an element with a class, register it with the browser, and from then on the browser treats <my-widget> the way it treats <div> or <button>: it parses it, instantiates your class, and runs your code at the right moments. The element gets its own methods, properties, events, and rendering logic.
There are two flavors, and the difference matters for both syntax and semantics:
- Autonomous custom elements are all-new tags that extend the abstract
HTMLElementclass. - Customized built-in elements extend a concrete built-in class, such as a customized button based on
HTMLButtonElement.
We will build autonomous elements first, then come back to the customized built-in kind.
The lifecycle callbacks
To define a custom element you write a class, and the browser calls specific methods on it at specific moments — when the element is created, added to the page, removed, and so on. All of these methods are optional; you implement only the ones you need.
Here is the full menu:
class MyElement extends HTMLElement {
constructor() {
super();
// the element instance was just created (not yet in the page)
}
connectedCallback() {
// runs when the element is inserted into the document
// (can fire multiple times if the element is added/removed repeatedly)
}
disconnectedCallback() {
// runs when the element is removed from the document
// (can also fire multiple times)
}
static get observedAttributes() {
return [/* attribute names to watch for changes */];
}
attributeChangedCallback(name, oldValue, newValue) {
// runs when one of the observed attributes above changes
}
adoptedCallback() {
// runs when the element moves to a new document
// (via document.adoptNode — rare)
}
// your own extra methods and properties can live here too
}
The lifecycle reads like a timeline: created, then connected, then possibly reacting to attribute changes while it lives on the page, then disconnected.
(repeats on each change)
Once the class exists, you register it so the browser knows which tag it powers:
// tell the browser that <my-element> is handled by our class
customElements.define("my-element", MyElement);
From now on, every <my-element> in the HTML produces an instance of MyElement, and the callbacks above fire at the right times. You can also create one from script with document.createElement('my-element').
The easiest way to feel the lifecycle is to watch it fire. Add the element to the page and back out again, and see which callbacks run each time. Try clicking Add twice in a row, or Remove when nothing is there:
Example: <money-formatted>
Numbers are everywhere in an interface — prices, totals, balances — but a raw 1299.5 is not how you show money to a person. Let’s build a <money-formatted> element that renders an amount in a readable, locale-aware way.
<script>
class MoneyFormatted extends HTMLElement { // (1)
connectedCallback() {
let amount = Number(this.getAttribute('amount') || 0);
this.innerHTML = new Intl.NumberFormat(undefined, {
style: this.getAttribute('currency') ? 'currency' : undefined,
currency: this.getAttribute('currency') || undefined,
notation: this.getAttribute('notation') || undefined,
signDisplay: this.getAttribute('sign-display') || undefined,
minimumFractionDigits: this.getAttribute('min-fraction') || undefined,
maximumFractionDigits: this.getAttribute('max-fraction') || undefined,
}).format(amount);
}
}
customElements.define("money-formatted", MoneyFormatted); // (2)
</script>
<!-- (3) -->
<money-formatted amount="1299.5"
currency="EUR" min-fraction="2" max-fraction="2"
sign-display="auto"
></money-formatted>
- The class has a single method,
connectedCallback(). The browser calls it when<money-formatted>lands in the page (which happens as the HTML parser reaches the tag). Inside, it reads the tag’s attributes and feeds them to the built-inIntl.NumberFormatformatter, which is well supported across browsers, then writes the formatted string into the element. - We register the tag with
customElements.define(tag, class). - After that, the tag works anywhere in the page.
Notice the || undefined on each attribute. getAttribute returns null for a missing attribute, and Intl.NumberFormat wants each option to be either a valid value or absent. Coercing null to undefined makes the formatter skip options you did not set.
Here it is running for real. Open the code and change the amount or currency, or drop one of the formatting attributes, to see how the output shifts:
Observing attributes
The <money-formatted> above renders once and then goes stale: change an attribute afterward and nothing happens. That is unusual for an HTML element. Set a.href and the link updates immediately; you expect the same responsiveness here. Let’s wire it up.
You tell the browser which attributes to watch by returning their names from a static observedAttributes() getter. For any attribute on that list, the browser calls attributeChangedCallback whenever it changes. Attributes not on the list are ignored — that is a deliberate performance choice, so the browser is not forced to notify you about every stray attribute.
Here is <money-formatted> rebuilt to re-render on every observed change:
<script>
class MoneyFormatted extends HTMLElement {
render() { // (1)
let amount = Number(this.getAttribute('amount') || 0);
this.innerHTML = new Intl.NumberFormat(undefined, {
style: this.getAttribute('currency') ? 'currency' : undefined,
currency: this.getAttribute('currency') || undefined,
notation: this.getAttribute('notation') || undefined,
signDisplay: this.getAttribute('sign-display') || undefined,
minimumFractionDigits: this.getAttribute('min-fraction') || undefined,
maximumFractionDigits: this.getAttribute('max-fraction') || undefined,
}).format(amount);
}
connectedCallback() { // (2)
if (!this.rendered) {
this.render();
this.rendered = true;
}
}
static get observedAttributes() { // (3)
return ['amount', 'currency', 'notation', 'sign-display', 'min-fraction', 'max-fraction'];
}
attributeChangedCallback(name, oldValue, newValue) { // (4)
this.render();
}
}
customElements.define("money-formatted", MoneyFormatted);
</script>
<money-formatted id="elem" currency="USD" min-fraction="2" max-fraction="2"></money-formatted>
<script>
setInterval(() => elem.setAttribute('amount', (Math.random() * 1000).toFixed(2)), 1000); // (5)
</script>
- The rendering logic moves into a reusable
render()method. connectedCallbackrenders once when the element enters the page. Therenderedguard matters:attributeChangedCallbackcan fire beforeconnectedCallbackduring parsing (as attributes are read off the opening tag), so the flag prevents a redundant first render and keeps the two callbacks from stepping on each other.observedAttributeslists every attribute that should trigger a redraw.- When any of those changes,
attributeChangedCallbackre-renders. - With that in place, a one-line
setIntervalturns the element into a live ticking price.
The result really does tick. Every second the setInterval rewrites the amount attribute, attributeChangedCallback fires, and render() redraws — no manual redraw call anywhere:
Rendering order
As the HTML parser builds the DOM, it processes elements one at a time, parents before children. For <outer><inner></inner></outer>, the <outer> element is created and connected first, then <inner>. That ordering has real consequences for custom elements.
Suppose a custom element reads its own innerHTML inside connectedCallback:
<script>
customElements.define('member-card', class extends HTMLElement {
connectedCallback() {
alert(this.innerHTML); // empty (*)
}
});
</script>
<member-card>Maya</member-card>
Run it and the alert is empty. At the moment connectedCallback fires, the parser has connected <member-card> but has not yet reached its children. The DOM subtree is still under construction, so there are no children to read.
innerHTML is “”
If you only need to pass data into the element, use attributes — they are available right away, even in the constructor’s aftermath, and certainly by connectedCallback. But if you genuinely need the child content, you can defer access with a zero-delay setTimeout:
<script>
customElements.define('member-card', class extends HTMLElement {
connectedCallback() {
setTimeout(() => alert(this.innerHTML)); // Maya (*)
}
});
</script>
<member-card>Maya</member-card>
Now line (*) alerts “Maya”. The callback scheduled by setTimeout runs after the current parsing work drains, by which point the children exist. That is your chance to inspect them and finish setup.
This trick has a wrinkle. If nested custom elements each defer with setTimeout, their deferred callbacks queue in the order they were scheduled, so an outer element’s deferred work runs before an inner element’s:
<script>
customElements.define('member-card', class extends HTMLElement {
connectedCallback() {
alert(`${this.id} connected.`);
setTimeout(() => alert(`${this.id} initialized.`));
}
});
</script>
<member-card id="outer">
<member-card id="inner"></member-card>
</member-card>
The output order is:
outer connected.inner connected.outer initialized.inner initialized.
So the outer element finishes its deferred initialization (step 3) before the inner one (step 4). If your outer element depends on its children already being fully initialized, that ordering can bite you.
There is no built-in callback that fires once all nested elements are ready. You have to arrange it yourself. One common pattern: have each inner element dispatch a custom event such as initialized when it is done, and let the outer element listen for those and react when its children report in.
Customized built-in elements
Brand-new tags like <money-formatted> carry no semantics. Search engines do not know what they mean, and assistive technologies have nothing to work with. Sometimes that is fine; sometimes it is a real problem. A search crawler benefits from knowing a chunk of text is a price. And if you are building a fancy button, throwing away everything <button> already does — keyboard activation, focus behavior, the disabled attribute, form participation — is wasteful.
Instead, you can extend a built-in element and inherit all of that. Buttons are instances of HTMLButtonElement, so we build on it.
The recipe has three parts:
-
Extend the built-in class with your own:
class LikeButton extends HTMLButtonElement { /* custom element methods */ } -
Pass a third argument to
customElements.definenaming the tag you extend:customElements.define('like-button', LikeButton, {extends: 'button'});This third argument is required because several tags can map to the same DOM class. For instance,
<q>and<blockquote>are bothHTMLQuoteElement, so the class alone would not tell the browser which tag you mean. -
In the HTML, write the ordinary built-in tag and add an
is="..."attribute pointing at your custom name — you do not invent a new tag here:<button is="like-button">...</button>
Putting it together:
<script>
// A button that tallies how many likes it collected
class LikeButton extends HTMLButtonElement {
constructor() {
super();
let likes = 0;
this.addEventListener('click', () => {
likes++;
this.textContent = 'Liked ×' + likes;
});
}
}
customElements.define('like-button', LikeButton, {extends: 'button'});
</script>
<button is="like-button">Liked ×0</button>
<button is="like-button" disabled>Disabled</button>
Because this really is a <button>, it keeps the native styling and standard behavior for free — the disabled example below is greyed out and unclickable without any extra code from us. Click the enabled one; the disabled one ignores you, exactly as a plain button would:
(On engines without is= support the buttons still render as ordinary buttons — they simply skip the greeting.)
References
- HTML Living Standard: https://html.spec.whatwg.org/#custom-elements.
- Compatibility: https://caniuse.com/custom-elementsv1.
Summary
Custom elements come in two types.
1. Autonomous — new tags that extend HTMLElement:
class MyElement extends HTMLElement {
constructor() { super(); /* ... */ }
connectedCallback() { /* ... */ }
disconnectedCallback() { /* ... */ }
static get observedAttributes() { return [/* ... */]; }
attributeChangedCallback(name, oldValue, newValue) { /* ... */ }
adoptedCallback() { /* ... */ }
}
customElements.define('my-element', MyElement);
// <my-element>
2. Customized built-in — extensions of existing elements. These need the extra .define argument plus is="..." in the HTML:
class MyButton extends HTMLButtonElement { /* ... */ }
customElements.define('my-button', MyButton, {extends: 'button'});
// <button is="my-button">
The lifecycle callbacks — constructor, connectedCallback, disconnectedCallback, attributeChangedCallback, adoptedCallback — give you hooks for creation, insertion, removal, attribute changes, and cross-document moves. Render in connectedCallback (attributes are ready by then), watch attributes through observedAttributes, and remember that during parsing a parent connects before its children exist.
Custom elements enjoy broad browser support. For older environments there is a polyfill at https://github.com/webcomponents/polyfills/tree/master/packages/webcomponentsjs.