Creating Reusable HTML Tags with Custom Elements

Custom Elements are a foundational capability of the modern Web Components standard, enabling web developers to invent their own fully functional, reusable HTML tags powered by JavaScript. By extending the browser’s built-in HTMLElement class, developers can encapsulate custom user interface logic, define reactive attributes, and control component lifecycles. This article explains how the Custom Elements API works, how JavaScript powers these components, and how they provide a native, framework-agnostic solution for modern web development.


The Mechanism Behind Custom Elements

At the core of the Custom Elements API is the ability to map a standard JavaScript class to a custom HTML tag name. This is handled natively by the browser without requiring external libraries or build tools.

To register a custom element, you use the global customElements.define() method:

class UserCard extends HTMLElement {
  constructor() {
    super();
    this.innerHTML = `<p>Hello, I am a custom element!</p>`;
  }
}

customElements.define('user-card', UserCard);

Once defined, the tag can be used directly in HTML just like any standard element:

<user-card></user-card>

Browsers require custom element names to contain a hyphen (-). This naming convention prevents naming collisions with existing and future standard HTML elements.


Powering Functionality with Lifecycle Callbacks

JavaScript drives the behavior of Custom Elements through specific lifecycle callbacks. These special methods execute automatically at different stages of an element’s existence in the DOM:

  1. connectedCallback(): Invoked every time the custom element is appended to a document. This is ideal for running setup code, fetching data, or rendering templates.
  2. disconnectedCallback(): Invoked when the element is removed from the DOM. This is used for cleanup tasks such as removing event listeners or clearing timers.
  3. attributeChangedCallback(name, oldValue, newValue): Invoked whenever an observed attribute is added, removed, or modified.
  4. adoptedCallback(): Invoked when the element is moved to a new document (such as when working with <iframe> elements).

Making Elements Reactive with Observed Attributes

Custom elements can listen for changes to their HTML attributes to dynamically update their appearance or internal state using JavaScript.

To observe attributes, you define a static getter named observedAttributes that returns an array of attribute names:

class StatusBadge extends HTMLElement {
  static get observedAttributes() {
    return ['status'];
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'status') {
      this.textContent = `Status: ${newValue}`;
    }
  }
}

customElements.define('status-badge', StatusBadge);

Whenever the status attribute is modified via JavaScript or standard HTML, the attributeChangedCallback fires immediately to reflect the change.


Encapsulation with the Shadow DOM

While Custom Elements provide the structure and behavior, they are frequently paired with the Shadow DOM to achieve full encapsulation. The Shadow DOM creates an isolated DOM tree attached to the element, preventing component styles from leaking into the main document and shielding internal elements from global CSS selectors.

class SecureButton extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <style>
        button {
          background-color: #007bff;
          color: white;
          border: none;
          padding: 8px 16px;
          border-radius: 4px;
        }
      </style>
      <button><slot>Click Me</slot></button>
    `;
  }
}

customElements.define('secure-button', SecureButton);

Advantages of Custom Elements