← Back to DevBytes

HTML Custom Elements: Complete Guide

What Are HTML Custom Elements?

HTML Custom Elements are a core part of the Web Components specification that allow developers to define their own fully-featured HTML elements. Instead of being limited to standard elements like <div>, <button>, or <input>, you can create custom tags such as <user-profile>, <star-rating>, or <animated-carousel> that encapsulate specific behavior, markup, and styling.

Custom Elements are powered by the customElements API, which lives on the global window object. They come in two flavors:

When you register a custom element, the browser treats it just like any built-in element. It participates in the regular DOM tree, responds to JavaScript queries, and can be styled with CSS. The real magic, however, is that each custom element instance has its own JavaScript class backing it, complete with lifecycle hooks, encapsulated state, and optional Shadow DOM for style isolation.

Why Custom Elements Matter

πŸš€ Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Before Custom Elements, component-based architectures were only possible through JavaScript frameworks like React, Vue, or Angular. Custom Elements bring componentization directly to the platform. Here's why that matters:

Creating Your First Autonomous Custom Element

Let's build a simple <greeting-badge> element from scratch. This element accepts a name attribute and displays a styled greeting message.

Step 1: Define the Class

Every custom element must be backed by a JavaScript class that extends HTMLElement:

class GreetingBadge extends HTMLElement {
  constructor() {
    super(); // You must call super() first
    this._name = 'World';
  }
}

Step 2: Add Lifecycle Methods

The browser calls specific methods at key moments in an element's life. The most important ones are connectedCallback() and attributeChangedCallback():

class GreetingBadge extends HTMLElement {
  constructor() {
    super();
    this._name = 'World';
  }

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

  attributeChangedCallback(name, oldValue, newValue) {
    // Called when an observed attribute changes
    if (name === 'name') {
      this._name = newValue || 'World';
      this.render();
    }
  }

  // Tell the browser which attributes to watch
  static get observedAttributes() {
    return ['name'];
  }

  render() {
    this.textContent = `Hello, ${this._name}! πŸ‘‹`;
  }
}

Step 3: Register the Element

After defining the class, you must register it with a tag name. Tag names for autonomous custom elements must contain a hyphen (e.g., my-element, user-card). This prevents collisions with future standard HTML elements.

customElements.define('greeting-badge', GreetingBadge);

Step 4: Use It in HTML

Once registered, you can use the element like any standard HTML tag:

<greeting-badge name="Alice"></greeting-badge>
<greeting-badge name="Bob"></greeting-badge>
<greeting-badge></greeting-badge> <!-- Falls back to "World" -->

Full working example:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Custom Element Demo</title>
</head>
<body>
  <greeting-badge name="Alice"></greeting-badge>
  <greeting-badge name="Bob"></greeting-badge>
  <greeting-badge></greeting-badge>

  <script>
    class GreetingBadge extends HTMLElement {
      constructor() {
        super();
        this._name = 'World';
      }

      connectedCallback() {
        this.render();
      }

      attributeChangedCallback(name, oldValue, newValue) {
        if (name === 'name') {
          this._name = newValue || 'World';
          this.render();
        }
      }

      static get observedAttributes() {
        return ['name'];
      }

      render() {
        this.textContent = `Hello, ${this._name}! πŸ‘‹`;
      }
    }

    customElements.define('greeting-badge', GreetingBadge);
  </script>
</body>
</html>

Customized Built-In Elements

Sometimes you want to extend an existing HTML element rather than create a brand-new one. For example, you might want a <button> that automatically shows a loading spinner when clicked. Customized built-in elements extend a specific HTML interface and are used with the is attribute.

To create one, extend the corresponding class (e.g., HTMLButtonElement) and register it with a third argument specifying the base element:

class SpinnerButton extends HTMLButtonElement {
  constructor() {
    super();
    this._listening = false;
  }

  connectedCallback() {
    if (!this._listening) {
      this.addEventListener('click', this._handleClick.bind(this));
      this._listening = true;
    }
  }

  _handleClick(e) {
    const originalText = this.textContent;
    this.disabled = true;
    this.textContent = 'Loading…';

    // Simulate async operation
    setTimeout(() => {
      this.disabled = false;
      this.textContent = originalText;
    }, 2000);
  }
}

customElements.define('spinner-button', SpinnerButton, { extends: 'button' });

Usage in HTML:

<button is="spinner-button">Click Me</button>

Important note: Customized built-in elements have limited support in some frameworks. Safari historically had issues with the is syntax, though this has improved. Many developers prefer autonomous custom elements for cross-browser consistency. For maximum compatibility, consider wrapping a standard button inside your autonomous custom element's Shadow DOM instead.

The Full Lifecycle Callbacks

Custom elements have four key lifecycle methods. Understanding their order and purpose is essential for building reliable components:

Here's a complete element demonstrating all four callbacks:

class TimerDisplay extends HTMLElement {
  constructor() {
    super();
    this._seconds = 0;
    this._intervalId = null;
    console.log('constructor called');
  }

  static get observedAttributes() {
    return ['running'];
  }

  connectedCallback() {
    console.log('connectedCallback: element added to DOM');
    this.render();
    if (this.hasAttribute('running')) {
      this._startTimer();
    }
  }

  disconnectedCallback() {
    console.log('disconnectedCallback: element removed from DOM');
    this._stopTimer();
  }

  attributeChangedCallback(name, oldValue, newValue) {
    console.log(`attributeChangedCallback: ${name} changed from ${oldValue} to ${newValue}`);
    if (name === 'running') {
      if (newValue !== null) {
        this._startTimer();
      } else {
        this._stopTimer();
      }
    }
  }

  _startTimer() {
    if (this._intervalId) return; // Already running
    this._intervalId = setInterval(() => {
      this._seconds++;
      this.render();
    }, 1000);
  }

  _stopTimer() {
    clearInterval(this._intervalId);
    this._intervalId = null;
  }

  render() {
    this.textContent = `Seconds elapsed: ${this._seconds}`;
  }
}

customElements.define('timer-display', TimerDisplay);

HTML usage:

<timer-display running></timer-display>

Working with Attributes and Properties

A well-designed custom element exposes its state through both HTML attributes and JavaScript properties, keeping them synchronized. This is sometimes called "reflecting" properties to attributes.

Here's a toggle-switch element that demonstrates the pattern:

class ToggleSwitch extends HTMLElement {
  constructor() {
    super();
    this._checked = false;
    this.attachShadow({ mode: 'open' });
  }

  static get observedAttributes() {
    return ['checked', 'disabled', 'label'];
  }

  // Property getter/setter with attribute reflection
  get checked() {
    return this._checked;
  }

  set checked(value) {
    this._checked = Boolean(value);
    if (this._checked) {
      this.setAttribute('checked', '');
    } else {
      this.removeAttribute('checked');
    }
    this._updateDisplay();
  }

  get disabled() {
    return this.hasAttribute('disabled');
  }

  set disabled(value) {
    if (value) {
      this.setAttribute('disabled', '');
    } else {
      this.removeAttribute('disabled');
    }
  }

  connectedCallback() {
    this._checked = this.hasAttribute('checked');
    this._render();
    this.shadowRoot.querySelector('.toggle').addEventListener('click', () => {
      if (!this.disabled) {
        this.checked = !this.checked;
        this.dispatchEvent(new CustomEvent('change', {
          detail: { checked: this.checked },
          bubbles: true,
          composed: true
        }));
      }
    });
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'checked') {
      this._checked = newValue !== null;
      this._updateDisplay();
    } else if (name === 'disabled') {
      this._updateDisplay();
    } else if (name === 'label') {
      this._updateLabel();
    }
  }

  _render() {
    this.shadowRoot.innerHTML = `
      <style>
        .toggle {
          display: inline-flex;
          align-items: center;
          cursor: pointer;
          user-select: none;
        }
        .switch {
          width: 50px;
          height: 28px;
          border-radius: 14px;
          background: #ccc;
          position: relative;
          transition: background 0.3s;
          margin-right: 8px;
        }
        .switch.checked {
          background: #4cd964;
        }
        .switch.disabled {
          opacity: 0.5;
          cursor: not-allowed;
        }
        .knob {
          width: 24px;
          height: 24px;
          border-radius: 12px;
          background: white;
          position: absolute;
          top: 2px;
          left: 2px;
          transition: left 0.3s;
          box-shadow: 0 1px 3px rgba(0,0,0,0.3);
        }
        .switch.checked .knob {
          left: 24px;
        }
        .label {
          font-family: sans-serif;
          font-size: 14px;
        }
      </style>
      <div class="toggle">
        <div class="switch" id="switch">
          <div class="knob"></div>
        </div>
        <span class="label"></span>
      </div>
    `;
    this._updateDisplay();
    this._updateLabel();
  }

  _updateDisplay() {
    const switchEl = this.shadowRoot?.getElementById('switch');
    if (!switchEl) return;
    if (this._checked) {
      switchEl.classList.add('checked');
    } else {
      switchEl.classList.remove('checked');
    }
    if (this.disabled) {
      switchEl.classList.add('disabled');
    } else {
      switchEl.classList.remove('disabled');
    }
  }

  _updateLabel() {
    const labelEl = this.shadowRoot?.querySelector('.label');
    if (labelEl) {
      labelEl.textContent = this.getAttribute('label') || '';
    }
  }
}

customElements.define('toggle-switch', ToggleSwitch);

Now you can use it with attributes or properties:

<!-- HTML attribute -->
<toggle-switch checked label="Notifications"></toggle-switch>

<script>
  // JavaScript property
  const toggle = document.querySelector('toggle-switch');
  console.log(toggle.checked); // true
  toggle.checked = false;      // Updates both property and attribute

  // Listen for changes
  toggle.addEventListener('change', (e) => {
    console.log('Toggle changed:', e.detail.checked);
  });
</script>

Shadow DOM and Style Encapsulation

Custom elements truly shine when combined with Shadow DOM. By calling this.attachShadow({ mode: 'open' }) in the constructor, you create a separate DOM subtree that is isolated from the main document. Styles defined inside the Shadow DOM don't leak out, and external styles don't penetrate in (except for inheritable properties like font-family and color).

Here's a complete <user-card> component with Shadow DOM encapsulation:

class UserCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._userData = null;
  }

  static get observedAttributes() {
    return ['user-id'];
  }

  connectedCallback() {
    const userId = this.getAttribute('user-id');
    if (userId) {
      this.fetchUser(userId);
    }
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'user-id' && newValue && newValue !== oldValue) {
      this.fetchUser(newValue);
    }
  }

  async fetchUser(userId) {
    // Simulated API call β€” replace with real fetch in production
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          border: 1px solid #e0e0e0;
          border-radius: 8px;
          padding: 16px;
          font-family: sans-serif;
          max-width: 300px;
          background: white;
          box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        }
        .avatar {
          width: 60px;
          height: 60px;
          border-radius: 50%;
          background: #7c3aed;
          color: white;
          display: flex;
          align-items: center;
          justify-content: center;
          font-size: 24px;
          font-weight: bold;
          margin-bottom: 12px;
        }
        .name {
          font-size: 18px;
          font-weight: 600;
          margin: 0 0 4px 0;
        }
        .email {
          color: #666;
          font-size: 14px;
          margin: 0 0 8px 0;
        }
        .bio {
          font-size: 14px;
          line-height: 1.4;
          color: #333;
        }
        .skeleton {
          background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
          background-size: 200% 100%;
          animation: shimmer 1.5s infinite;
          border-radius: 4px;
        }
        .skeleton-avatar {
          width: 60px;
          height: 60px;
          border-radius: 50%;
        }
        .skeleton-name {
          width: 120px;
          height: 20px;
          margin-bottom: 8px;
        }
        .skeleton-email {
          width: 180px;
          height: 16px;
          margin-bottom: 8px;
        }
        .skeleton-bio {
          width: 100%;
          height: 40px;
        }
        @keyframes shimmer {
          0% { background-position: -200% 0; }
          100% { background-position: 200% 0; }
        }
      </style>
      <div class="skeleton-avatar skeleton"></div>
      <div class="skeleton-name skeleton"></div>
      <div class="skeleton-email skeleton"></div>
      <div class="skeleton-bio skeleton"></div>
    `;

    // Simulate network delay
    await new Promise(resolve => setTimeout(resolve, 1000));

    // In a real app, you'd do: const response = await fetch(`/api/users/${userId}`);
    const mockUser = {
      name: 'Jane Doe',
      email: 'jane@example.com',
      bio: 'Software engineer and open source enthusiast. Loves building web components.'
    };
    this._userData = mockUser;
    this.renderUser();
  }

  renderUser() {
    const user = this._userData;
    const initials = user.name.split(' ').map(n => n[0]).join('');
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          border: 1px solid #e0e0e0;
          border-radius: 8px;
          padding: 16px;
          font-family: sans-serif;
          max-width: 300px;
          background: white;
          box-shadow: 0 2px 4px rgba(0,0,0,0.1);
          transition: box-shadow 0.2s;
        }
        :host(:hover) {
          box-shadow: 0 4px 8px rgba(0,0,0,0.15);
        }
        .avatar {
          width: 60px;
          height: 60px;
          border-radius: 50%;
          background: #7c3aed;
          color: white;
          display: flex;
          align-items: center;
          justify-content: center;
          font-size: 24px;
          font-weight: bold;
          margin-bottom: 12px;
        }
        .name {
          font-size: 18px;
          font-weight: 600;
          margin: 0 0 4px 0;
        }
        .email {
          color: #666;
          font-size: 14px;
          margin: 0 0 8px 0;
        }
        .bio {
          font-size: 14px;
          line-height: 1.4;
          color: #333;
        }
      </style>
      <div class="avatar">${initials}</div>
      <h2 class="name">${user.name}</h2>
      <p class="email">${user.email}</p>
      <p class="bio">${user.bio}</p>
    `;
  }
}

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

Use it simply as:

<user-card user-id="42"></user-card>

The Shadow DOM's :host selector lets you style the custom element itself from within its shadow tree. :host(:hover) applies styles when the user hovers over the element. The mode: 'open' option makes the shadow root accessible via element.shadowRoot for debugging; use mode: 'closed' if you want to prevent external access (though this is rarely necessary).

Dispatching Events from Custom Elements

Custom elements communicate with the outside world by dispatching DOM events. Always use bubbles: true and composed: true so events cross the Shadow DOM boundary and bubble up through the document tree:

class StarRating extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._value = 0;
  }

  static get observedAttributes() {
    return ['value', 'max'];
  }

  connectedCallback() {
    this._value = parseInt(this.getAttribute('value') || '0', 10);
    const max = parseInt(this.getAttribute('max') || '5', 10);
    this.render(max);
  }

  render(max) {
    let starsHtml = '';
    for (let i = 1; i <= max; i++) {
      const filled = i <= this._value ? 'filled' : '';
      starsHtml += `<span class="star ${filled}" data-index="${i}">β˜…</span>`;
    }

    this.shadowRoot.innerHTML = `
      <style>
        .stars { display: inline-flex; gap: 2px; }
        .star {
          font-size: 24px;
          color: #ccc;
          cursor: pointer;
          transition: color 0.2s, transform 0.1s;
          user-select: none;
        }
        .star.filled { color: #f5a623; }
        .star:hover { transform: scale(1.2); }
      </style>
      <div class="stars">${starsHtml}</div>
    `;

    this.shadowRoot.querySelectorAll('.star').forEach(star => {
      star.addEventListener('click', (e) => {
        const newValue = parseInt(e.target.dataset.index, 10);
        this.setAttribute('value', newValue);
        this._value = newValue;
        this.render(max);

        this.dispatchEvent(new CustomEvent('rating-change', {
          detail: { value: newValue },
          bubbles: true,
          composed: true
        }));
      });
    });
  }
}

customElements.define('star-rating', StarRating);

Consumers can listen for the event like any other DOM event:

<star-rating value="3" max="5"></star-rating>

<script>
  document.querySelector('star-rating').addEventListener('rating-change', (e) => {
    console.log(`User rated: ${e.detail.value} stars`);
    // Send to analytics, update UI, etc.
  });
</script>

Advanced Example: A Complete Tabs Component

Let's build a fully functional <tab-container> component that demonstrates slots, multiple custom elements working together, and complex state management. This example combines two custom elements: <tab-container> and <tab-panel>.

// tab-panel.js
class TabPanel extends HTMLElement {
  static get observedAttributes() {
    return ['active'];
  }

  connectedCallback() {
    this._updateVisibility();
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'active') {
      this._updateVisibility();
    }
  }

  _updateVisibility() {
    if (this.hasAttribute('active')) {
      this.style.display = 'block';
    } else {
      this.style.display = 'none';
    }
  }
}

// tab-container.js
class TabContainer extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._tabs = [];
    this._panels = [];
  }

  connectedCallback() {
    // Collect slotted tab headers and panels
    this._tabs = Array.from(this.querySelectorAll('[slot="tab"]'));
    this._panels = Array.from(this.querySelectorAll('tab-panel'));
    this.render();
    this._activateTab(0);
  }

  render() {
    const tabLabels = this._tabs.map((tab, i) => `
      <button class="tab-btn" data-index="${i}">
        ${tab.textContent}
      </button>
    `).join('');

    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          border: 1px solid #ddd;
          border-radius: 8px;
          overflow: hidden;
          font-family: sans-serif;
        }
        .tab-header {
          display: flex;
          background: #f5f5f5;
          border-bottom: 1px solid #ddd;
        }
        .tab-btn {
          flex: 1;
          padding: 12px 16px;
          border: none;
          background: transparent;
          cursor: pointer;
          font-size: 14px;
          font-weight: 500;
          color: #666;
          transition: background 0.2s, color 0.2s;
          border-bottom: 2px solid transparent;
        }
        .tab-btn:hover {
          background: #e8e8e8;
          color: #333;
        }
        .tab-btn.active {
          color: #7c3aed;
          border-bottom-color: #7c3aed;
          background: white;
        }
        .panel-container {
          padding: 16px;
          background: white;
        }
        ::slotted([slot="tab"]) {
          display: none;
        }
      </style>
      <div class="tab-header">${tabLabels}</div>
      <div class="panel-container">
        <slot name="panel"></slot>
      </div>
    `;

    this.shadowRoot.querySelectorAll('.tab-btn').forEach(btn => {
      btn.addEventListener('click', (e) => {
        const index = parseInt(e.target.dataset.index, 10);
        this._activateTab(index);
      });
    });
  }

  _activateTab(index) {
    // Update button states
    const buttons = this.shadowRoot.querySelectorAll('.tab-btn');
    buttons.forEach((btn, i) => {
      btn.classList.toggle('active', i === index);
    });

    // Update panel visibility
    this._panels.forEach((panel, i) => {
      if (i === index) {
        panel.setAttribute('active', '');
      } else {
        panel.removeAttribute('active');
      }
    });

    this.dispatchEvent(new CustomEvent('tab-change', {
      detail: { index, tab: this._tabs[index]?.textContent || '' },
      bubbles: true,
      composed: true
    }));
  }
}

customElements.define('tab-panel', TabPanel);
customElements.define('tab-container', TabContainer);

HTML usage with slots:

<tab-container>
  <span slot="tab">Home</span>
  <span slot="tab">Profile</span>
  <span slot="tab">Settings</span>

  <tab-panel slot="panel">
    <h3>Welcome Home</h3>
    <p>This is the home tab content.</p>
  </tab-panel>

  <tab-panel slot="panel">
    <h3>Your Profile</h3>
    <p>Edit your profile information here.</p>
  </tab-panel>

  <tab-panel slot="panel">
    <h3>Settings</h3>
    <p>Configure application settings.</p>
  </tab-panel>
</tab-container>

This pattern demonstrates how multiple custom elements can collaborate. The <tab-container> orchestrates the tabs, while each <tab-panel> manages its own visibility. Slots provide a clean way to define tab labels and panel content directly in HTML.

Best Practices for Custom Elements

After building many custom elements, certain patterns emerge that lead to robust, maintainable components. Here are the most important best practices:

Browser Support and Polyfills

Custom Elements v1 (the current specification) is supported in all modern browsers: Chrome, Firefox, Safari, and Edge. Internet Explorer does not support Custom Elements, but for projects that require legacy

πŸš€ Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started β€” $23.99/mo
← Back to all articles