Web Components: custom elements and shadow DOM

Define a custom element, use its lifecycle callbacks correctly, encapsulate styles in a shadow root, and compose with slots and events.

Defining a custom element

class RatingStars extends HTMLElement {
  static observedAttributes = ['value'];

  constructor() {
    super();
    // do not read attributes or children here - they may not exist yet
  }

  connectedCallback() {
    if (!this.shadowRoot) this.#render();
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue === newValue) return;
    if (this.shadowRoot) this.#update(newValue);
  }

  #render() {
    const root = this.attachShadow({ mode: 'open' });
    root.innerHTML =
      '<style>:host { display: inline-flex; gap: 2px; }</style>' +
      '<span part="stars"></span>';
    this.#update(this.getAttribute('value') ?? '0');
  }

  #update(value) {
    const n = Math.max(0, Math.min(5, Number(value) || 0));
    this.shadowRoot.querySelector('[part=stars]').textContent = '*'.repeat(n);
  }
}

customElements.define('rating-stars', RatingStars);
<rating-stars value="3"></rating-stars>
  • A custom element name must contain a hyphen, which is what keeps it from colliding with a future HTML element.
  • customElements.define throws if the name is already registered, so guard in hot-reload environments.
  • The constructor must not inspect attributes or children - they are not parsed yet. Render in connectedCallback.
  • attributeChangedCallback only fires for names listed in observedAttributes.

Shadow root, styles and slots

<template id="card">
  <style>
    :host { display: block; border: 1px solid var(--border, #ddd); }
    :host([disabled]) { opacity: 0.5; pointer-events: none; }
    .title { font-weight: 600; }
    ::slotted(img) { border-radius: 6px; }
  </style>
  <div class="title"><slot name="title">Untitled</slot></div>
  <div class="body"><slot></slot></div>
</template>
class SummaryCard extends HTMLElement {
  connectedCallback() {
    const root = this.attachShadow({ mode: 'open' });
    root.append(document.querySelector('#card').content.cloneNode(true));
  }

  get icons() {
    return this.shadowRoot.querySelector('slot[name="icon"]').assignedElements();
  }
}
SelectorScopeApplies to
:hostInside the shadow rootThe custom element itself
:host([disabled])InsideThe element, when the attribute is present
::slotted(sel)InsideLight-DOM children placed in a slot
::part(name)OutsideThemed from the page via part
:root custom propertiesInherited inThe supported theming channel
💡
Styles do not leak in and selectors do not leak out, but inherited properties and CSS custom properties still cross the boundary. That is not a loophole - it is the intended theming API, and it is why theming a web component is done with custom properties rather than by overriding its internal classes.

Events and composition

class QuantityPicker extends HTMLElement {
  #input;

  connectedCallback() {
    const root = this.attachShadow({ mode: 'open' });
    root.innerHTML = '<button data-step="-1">-</button>' +
      '<input value="1" inputmode="numeric">' +
      '<button data-step="1">+</button>';

    this.#input = root.querySelector('input');

    // retargeted events bring the click out of the shadow root
    root.addEventListener('click', (event) => {
      const button = event.target.closest('[data-step]');
      if (!button) return;
      const next = Math.max(1, Number(this.#input.value) + Number(button.dataset.step));
      this.#input.value = String(next);

      this.dispatchEvent(new CustomEvent('quantity-change', {
        detail: { quantity: next },
        bubbles: true,
        composed: true,
      }));
    });
  }

  get value() { return Number(this.#input.value); }
  set value(v) { this.#input.value = String(v); }
}

customElements.define('quantity-picker', QuantityPicker);
  • composed: true is what lets an event escape the shadow root; without it the event stops at the boundary.
  • Inside a shadow root, event.target is retargeted to the host element for listeners outside - use composedPath() when you need the real source.
  • Prefer properties over attributes for values that are not strings: attributes are strings and always will be.
  • Reflect a property back to an attribute only when CSS needs to match on it, such as :host([disabled]).
// form-associated custom elements participate in FormData
class MoneyInput extends HTMLElement {
  static formAssociated = true;
  #internals;

  constructor() {
    super();
    this.#internals = this.attachInternals();
  }

  set value(v) {
    this.#internals.setFormValue(v);
  }
}

FAQ

Open or closed shadow root?
Open, unless you have a specific reason. closed prevents element.shadowRoot from working, which breaks debugging, testing and any tool that needs to inspect the tree - and it does not stop a determined script, because the element still exposes the same internals through other paths.
Why is my custom element not upgrading?
The definition ran after the element was parsed, or the tag name does not match. Elements are upgraded automatically once define is called, but anything you moved with innerHTML and did not reconnect stays un-upgraded. Use customElements.whenDefined(name) when ordering matters.

Events and delegation in depth Observing changes with MutationObserver

Last refreshed 2026-09-18.