jsguides

Custom Elements Deep Dive

Custom Elements let you define your own HTML tags — fully reusable components with encapsulated behaviour. Part of the Web Components spec, they work alongside Shadow DOM and HTML templates. Browser support has been solid since early 2020.

Two Types of Custom Elements

Autonomous custom elements extend HTMLElement directly. They don’t inherit from any existing tag:

class MyButton extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: "open" });
  }
  connectedCallback() {
    this.shadowRoot.innerHTML = `<button>Click me</button>`;
  }
}

customElements.define("my-button", MyButton);

Use it: <my-button></my-button> anywhere in your HTML.

Customized built-in elements extend a specific element type. They inherit the behaviour of that element:

class MagicLink extends HTMLAnchorElement {
  constructor() {
    super();
    this.addEventListener("click", e => {
      e.preventDefault();
      console.log("Magic link clicked!");
    });
  }
}

customElements.define("magic-link", MagicLink, { extends: "a" });

Use it: <a is="magic-link" href="/somewhere">Click</a>. The is attribute tells the browser to use your custom element.

Most developers use autonomous custom elements. Customized built-in elements have trickier browser support and are less common in practice.

Lifecycle Callbacks

Custom Elements give you four lifecycle callbacks:

class StatusIndicator extends HTMLElement {
  // Called when the element is added to the DOM
  connectedCallback() {
    this.render();
  }

  // Called when the element is removed from the DOM
  disconnectedCallback() {
    console.log("Element removed from DOM");
  }

  // Called when an attribute changes
  attributeChangedCallback(name, oldValue, newValue) {
    if (name === "status" && oldValue !== newValue) {
      this.render();
    }
  }

  // Called when the element is moved to a new document
  adoptedCallback(oldDocument, newDocument) {
    console.log("Element moved to new document");
  }

  static get observedAttributes() {
    return ["status"];
  }
}

connectedCallback is the most common — it runs when the element is first inserted and whenever it’s re-added to the DOM. Use it to set up your component’s initial state and structure.

disconnectedCallback is for cleanup: remove event listeners, cancel timers, unregister observers. Keeping the DOM clean prevents memory leaks.

attributeChangedCallback only fires for attributes listed in static get observedAttributes(). Adding this getter is required for the callback to work at all.

adoptedCallback fires when document.adoptedNode() moves the element between documents — rare in most applications.

Shadow DOM

Shadow DOM lets you encapsulate markup and styles so they don’t bleed out (or in):

class HelloWorld extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: "open" });
    shadow.innerHTML = `
      <style>
        p { color: steelblue; font-weight: bold; }
      </style>
      <p>Hello, <slot></slot>!</p>
    `;
  }
}

customElements.define("hello-world", HelloWorld);
<hello-world>World</hello-world>
<!-- Renders: <p>Hello, World!</p> -->

The <slot> element is a placeholder — text nodes that appear inside your element get projected into the slot.

With mode: "closed" on the shadow root, external JavaScript can’t access element.shadowRoot. Most components use open for debugging convenience.

Observed Attributes

Attributes and properties should stay in sync. The standard pattern:

class ProgressBar extends HTMLElement {
  static get observedAttributes() {
    return ["value", "max"];
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue !== newValue) {
      this.render();
    }
  }

  get value() {
    return Number(this.getAttribute("value") || 0);
  }

  set value(v) {
    this.setAttribute("value", v);
  }

  render() {
    const percent = (this.value / this.max) * 100;
    this.style.width = `${percent}%`;
  }
}

Setting element.value = 50 and setting <element value="50"> both trigger render().

Upgrading Custom Elements

Custom elements upgrade when the parser encounters them — before your script finishes loading. A placeholder element exists immediately:

<my-widget>Loading...</my-widget>
<script>
  // my-widget exists in DOM even before this runs
  console.log(document.querySelector("my-widget") instanceof HTMLElement); // true
</script>

This means you can render a skeleton UI and fill it in once the class is defined. The customElements.whenDefined() promise resolves when your element is ready:

customElements.whenDefined("my-widget").then(() => {
  console.log("my-widget is now defined!");
});

Slots and Distributed Children

Slots let you project children into your shadow DOM:

class TabPanel extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: "open" });
    this.shadowRoot.innerHTML = `
      <style>
        :host { display: block; border: 1px solid #ccc; padding: 1em; }
        ::slotted([slot="title"]) { font-weight: bold; font-size: 1.2em; margin-bottom: 0.5em; }
      </style>
      <slot name="title"></slot>
      <slot></slot>
    `;
  }
}

customElements.define("tab-panel", TabPanel);
<tab-panel>
  <span slot="title">Settings</span>
  <p>Panel content goes here.</p>
</tab-panel>

Children with slot="title" project into the named slot. Unslotted children project into the default slot.

CSS Custom Properties and Shadow DOM

Styles inside shadow DOM are scoped. Use CSS custom properties to let external styles bleed in:

this.shadowRoot.innerHTML = `
  <style>
    :host {
      background: var(--panel-bg, #ffffff);
      color: var(--panel-color, inherit);
      padding: var(--panel-padding, 1em);
    }
  </style>
  <slot></slot>
`;
<my-panel style="--panel-bg: #f5f5f5;"></my-panel>

The component defines the property names; the consumer provides the values. This is the standard way to theme custom elements without JavaScript.

Form-Associated Custom Elements

Custom elements can participate in forms by adding static get formAssociated() and implementing the formAssociatedCallback:

class ColorPicker extends HTMLElement {
  static get formAssociated() { return true; }

  constructor() {
    super();
    this._internals = this.attachInternals();
  }

  valueChanged(newValue) {
    this._internals.setFormValue(newValue);
  }
}

customElements.define("color-picker", ColorPicker);

This lets your element work with native form features: FormData, validation, and the form attribute selector.

Best Practices

Keep custom elements focused and single-purpose. Expose a minimal API — properties and attributes for configuration, events for notifications. Don’t reach into the shadow DOM from outside the element; let CSS custom properties and slot projection handle styling and composition.

See Also