Kodokon kodokon.com

Web Components: custom elements, shadow DOM and slots

Master the custom element lifecycle, shadow DOM encapsulation and content projection through slots.

12 min · 3 questions

Open this lesson in Kodokon

Custom elements extend the HTML vocabulary: any class inheriting from HTMLElement can be registered in the CustomElementRegistry, as long as its name contains a hyphen (user-badge, never userbadge). Subtle point: if the tag appears in the document before customElements.define is called, it is first created as an unknown element and then upgraded in place - the constructor is then invoked after the fact on a node already present in the tree.

JAVASCRIPT
class UserBadge extends HTMLElement {
  static observedAttributes = ["name"];
  connectedCallback() {
    this.render();
  }
  attributeChangedCallback() {
    this.render();
  }
  render() {
    this.textContent = this.getAttribute("name");
  }
}
customElements.define("user-badge", UserBadge);
A minimal custom element with a complete lifecycle.

The lifecycle holds some traps: attributeChangedCallback only fires for the attributes listed in observedAttributes, and during an upgrade it is called for every attribute already present before connectedCallback. The specification forbids the constructor from inspecting attributes and children, or from adding any: at the moment it runs (via createElement or cloning), the element may be completely empty.

HTML
<template id="card-tpl">
  <style>
    :host { display: block; padding: 1rem; }
    ::slotted(h2) { margin: 0; }
  </style>
  <slot name="title"></slot>
  <slot></slot>
</template>
An inert template: parsed, but neither executed nor rendered.

attachShadow creates an encapsulated subtree: styles do not leak out of it, the page's selectors do not reach into it - only inherited CSS properties (color, font) and custom properties cross the boundary. The content of a <template> lives in a DocumentFragment accessible via .content: its scripts do not run and its images do not load until you clone it into the document. Nodes placed in the light DOM are projected into the <slot> elements: they physically remain in the light DOM, and the slotchange event notifies you of any change in assignment.

JAVASCRIPT
class InfoCard extends HTMLElement {
  constructor() {
    super();
    const tpl = document.getElementById("card-tpl");
    this.attachShadow({ mode: "open" })
      .appendChild(tpl.content.cloneNode(true));
  }
}
customElements.define("info-card", InfoCard);
Cloning the template into an open shadow root.

Two extensions deserve your attention. Form-associated custom elements: with static formAssociated = true and attachInternals(), your component fully participates in forms (submitted value, native validation, :invalid states). And declarative shadow DOM: <template shadowrootmode=open> attaches a shadow root at parse time, without JavaScript, which makes web components compatible with server-side rendering.

Knowledge check

Make sure you remember the key points of this lesson.

  1. Why must a custom element's constructor neither read its attributes nor create children?
    • Because attributes are frozen read-only before connectedCallback
    • Because the shadow DOM hides the light DOM during construction
    • Because at that moment (createElement, cloning), the element may be empty; the specification requires a constructor with no effect on the content
  2. With attachShadow({ mode: 'closed' }), what does element.shadowRoot return from the outside?
    • null: only the reference returned by attachShadow gives access to the shadow root
    • The shadow root, but read-only
    • An empty DocumentFragment
    • A DOMException is thrown
  3. Where do the nodes projected into a <slot> physically live?
    • They are moved into the shadow DOM at render time
    • They stay in the light DOM; the slot merely displays them at its position in the flattened tree
    • They are cloned: the original stays in the light DOM, the copy lives in the shadow DOM