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

connectedCallback()

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.

disconnectedCallback()

The element is removed from a connected document. The natural place to release resources acquired in connectedCallback() (timers, event listeners on external objects, subscriptions).

attributeChangedCallback(name, oldValue, newValue)

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 connectedCallback().

adoptedCallback()

The element is moved into a different Document via document.adoptNode() — rare in typical application code.

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);
sequenceDiagram participant Parser as HTML Parser / new() participant Elem as Custom Element Instance participant DOM as Document Parser->>Elem: constructor() Note over Elem: super() called first
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.