Web Components
|
This section documents modern ECMAScript and core browser JavaScript APIs — it is not tied to any specific framework or library (React, Vue, Angular, etc.). This content was generated with the assistance of AI and should be verified against the current ECMAScript specification and MDN documentation before relying on it in production, since JavaScript language features and browser API support continue to evolve. This section’s bibliography lists the reference material consulted while preparing these pages. |
Web Components is a set of platform APIs — custom elements, the Shadow DOM, and the <template>/<slot>
elements — that together let a script define a genuinely new, reusable HTML tag: one with its own JavaScript
behavior, encapsulated internal markup/styling, and a declarative usage syntax indistinguishable from a built-in
element like <video> or <select>. Unlike the framework-specific component models covered elsewhere in this
documentation set, these are native browser APIs with no library or build step required.
Defining a Custom Element
A custom element is a JavaScript class that extends HTMLElement (or one of its subclasses), registered with the
browser under a tag name via customElements.define():
class GreetingBanner extends HTMLElement {
constructor() {
super(); // required first statement -- HTMLElement's own constructor sets up the element
}
}
customElements.define("greeting-banner", GreetingBanner);
Once registered, <greeting-banner></greeting-banner> can be used anywhere in the document’s markup, created with
document.createElement("greeting-banner"), or instantiated directly with new GreetingBanner(), and behaves
like any other Element — it can be queried, styled, and have attributes/event listeners attached to it.
Custom element names are required to contain a hyphen (greeting-banner, not greeting) — this is
how the HTML parser tells a custom element apart from a possible future built-in tag with the same name, and it
is enforced: customElements.define() throws if the name has no hyphen.
|
The constructor runs when the element is created (by the parser or by document.createElement()/new), but the
element is not yet guaranteed to be attached to the document or to have its attributes parsed — it exists only
as a detached HTMLElement at this point. The constructor is therefore the right place to set up internal state
and (as covered below) attach a shadow root, but the wrong place to read attributes or do anything that assumes
the element is on the page; that work belongs in the lifecycle callbacks instead.
Lifecycle Callbacks
HTMLElement invokes several specially-named methods automatically, if the subclass defines them, at specific
points in the element’s life:
| Callback | Invoked when |
|---|---|
|
The element is inserted into a document that’s connected to the page (including the initial parse). Can run more than once if the element is removed and reinserted. |
|
The element is removed from a connected document. The natural place to release resources acquired in
|
|
An observed attribute is added, removed, or changed — see below. Also fires once for each observed
attribute already present when the element is first parsed, before |
|
The element is moved into a different |
attributeChangedCallback() only fires for attributes the class explicitly opts into, via a static
observedAttributes getter returning an array of attribute names:
class GreetingBanner extends HTMLElement {
static get observedAttributes() {
return ["name"];
}
connectedCallback() {
this.textContent = `Hello, ${this.getAttribute("name") ?? "there"}!`;
}
attributeChangedCallback(attrName, oldValue, newValue) {
if (attrName === "name" && this.isConnected) {
this.textContent = `Hello, ${newValue ?? "there"}!`;
}
}
}
customElements.define("greeting-banner", GreetingBanner);
element is detached, not yet parsed Parser->>Elem: attributeChangedCallback() Note over Elem: once per observed attribute
already present in markup Parser->>DOM: insert into connected document DOM->>Elem: connectedCallback() Note over Elem: element is now live on the page rect rgba(128,128,128,0.1) Note over Elem: later, if an observed attribute changes DOM->>Elem: attributeChangedCallback() end DOM->>Elem: disconnectedCallback() Note over Elem: element removed from the document
attributeChangedCallback() can run before connectedCallback() (for attributes present in the initial
markup) as shown above, so code inside it that also needs the element to be connected should guard with
this.isConnected, exactly as the example does.
|
Shadow DOM
attachShadow({mode: "open"}), called on an element (typically from the constructor), gives that element its own
shadow root: a separate, encapsulated DOM subtree with its own scoped styles and markup, hidden from
document.querySelector() and from the page’s own CSS rules (and vice versa — the shadow tree’s styles don’t
leak out either):
class GreetingBanner extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: "open" });
shadow.innerHTML = `
<style>
strong { color: rebeccapurple; }
</style>
<p>Hello, <strong id="name"></strong>!</p>
`;
}
connectedCallback() {
this.shadowRoot.getElementById("name").textContent = this.getAttribute("name") ?? "there";
}
}
mode: "open" exposes the shadow root via the element’s own .shadowRoot property, as used above; mode:
"closed" hides it (.shadowRoot returns null), which is rarely necessary and makes the component harder to
inspect and test. The light DOM — the element’s own children, as written in the page’s markup — still exists
alongside the shadow tree; <slot> (below) is how it gets displayed.
<template> and <slot>
A <template> element’s content is parsed but never rendered and never runs embedded scripts/styles until it is
explicitly cloned into the document — it’s inert markup, ideal for a reusable stamp of DOM that a custom element
instantiates once per instance:
<template id="card-template">
<style>
.card { border: 1px solid #ccc; padding: 0.5em; }
</style>
<div class="card">
<slot name="title">Untitled</slot>
<slot></slot>
</div>
</template>
class InfoCard extends HTMLElement {
constructor() {
super();
const template = document.getElementById("card-template");
const shadow = this.attachShadow({ mode: "open" });
shadow.appendChild(template.content.cloneNode(true)); // deep-clone the template's inert content
}
}
customElements.define("info-card", InfoCard);
<slot> marks a placement inside the shadow tree where the element’s light-DOM children (its actual markup
content, as written by whoever uses the custom element) get projected. A named <slot name="…"> receives only
light-DOM children carrying a matching slot="…" attribute; an unnamed <slot> receives everything else. A
slot’s own content (Untitled, above) is fallback, shown only when nothing is projected into it:
<info-card>
<span slot="title">Shipping Update</span>
Your package left the warehouse today.
</info-card>
Here, "Shipping Update" is projected into the named title slot, and the plain text node is projected into the
unnamed slot — both while card-template’s `<style> and layout stay fully encapsulated inside the shadow tree,
invisible to and unaffected by the rest of the page’s CSS.
Worked Example: A Complete Custom Element
Putting the pieces together — a <user-card> element that reads its data from attributes, uses a shadow root
and template for encapsulated markup, and projects a light-DOM child into a named slot:
const template = document.createElement("template");
template.innerHTML = `
<style>
.card { border: 1px solid #ccc; border-radius: 4px; padding: 0.75em; font-family: sans-serif; }
.name { font-weight: bold; }
</style>
<div class="card">
<div class="name"></div>
<slot name="bio">No bio provided.</slot>
</div>
`;
class UserCard extends HTMLElement {
static get observedAttributes() {
return ["name"];
}
constructor() {
super();
this.attachShadow({ mode: "open" }).appendChild(template.content.cloneNode(true));
}
connectedCallback() {
this.#render();
}
attributeChangedCallback() {
if (this.isConnected) this.#render();
}
#render() {
this.shadowRoot.querySelector(".name").textContent = this.getAttribute("name") ?? "Unknown";
}
}
customElements.define("user-card", UserCard);
<user-card name="Ada Lovelace">
<p slot="bio">Mathematician and writer, known for work on Babbage's Analytical Engine.</p>
</user-card>
#render (a private class field method — see Classes) is called from both
connectedCallback() and attributeChangedCallback() so the displayed name stays correct whether it was present
in the initial markup or set/changed afterward with userCardElement.setAttribute("name", …).
Where This Fits
Web Components compose with the rest of the DOM APIs covered on
Web Programming Basics — a custom element instance is still an
ordinary Element, so element creation, attribute access, and dataset all work on it exactly as described
there. Event handling inside a custom element (including events that need to cross the shadow boundary) follows
the same rules covered on Events.