← Back to DevBytes

Implementing HTML Custom Elements in Modern Web Applications

Understanding HTML Custom Elements

Custom elements are a cornerstone of the Web Components suite, allowing developers to define their own fully-functional HTML elements with encapsulated behavior and rendering. Unlike framework-specific components, custom elements are native to the browser — they work everywhere HTML works, without dependencies or build steps.

What Are Custom Elements?

At their core, custom elements are classes that extend HTMLElement and register themselves with the browser via customElements.define(). Once registered, you can use them just like any standard HTML tag — drop them into markup, attach event listeners, style them with CSS, and query them with document.querySelector(). The browser treats them as first-class citizens of the DOM.

Two types of custom elements exist:

Why Custom Elements Matter

Custom elements solve real problems in modern web development. They provide a standards-based way to package UI logic that is framework-agnostic, portable, and long-lived. A custom element you build today will work in any framework or vanilla JS context ten years from now — because it relies on the web platform, not a library's release cycle.

Key benefits include:

Getting Started: Defining a Custom Element

🚀 Deploy your AI agent in 10 minutes

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

Try it free →

The minimal recipe is straightforward: write a class, register it, and use the tag in your HTML. The class must extend HTMLElement (or a more specific HTML interface), and the tag name must contain a hyphen to avoid clashes with future standard elements.

A Basic Example

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Custom Element Demo</title>
</head>
<body>
  <greeting-banner></greeting-banner>
  <script src="greeting-banner.js"></script>
</body>
</html>
// greeting-banner.js
class GreetingBanner extends HTMLElement {
  constructor() {
    super();
    // Constructor logic runs when the element is created
    // (before it's connected to the DOM)
  }

  connectedCallback() {
    // Called when the element is inserted into the DOM
    this.textContent = 'Hello, welcome to our site!';
    this.setAttribute('role', 'status');
    this.style.cssText = `
      display: block;
      padding: 1rem;
      background: #4a90d9;
      color: white;
      font-family: sans-serif;
      border-radius: 6px;
    `;
  }
}

// Register the element with the browser
customElements.define('greeting-banner', GreetingBanner);

Load this in a browser, and the <greeting-banner> tag renders as a styled banner. No framework, no compilation — just a JavaScript file and standard HTML.

Lifecycle Callbacks

Custom elements come with a set of lifecycle hooks that let you respond to key moments in an element's existence. Understanding these is essential for building robust components.

class LifecycleDemo extends HTMLElement {
  constructor() {
    super();
    console.log('constructor: element created, may not be in DOM yet');
    // Don't inspect children or attributes here — they may not exist yet
  }

  connectedCallback() {
    console.log('connectedCallback: element added to DOM');
    // Ideal place to set up event listeners, fetch data, render content
    this.innerHTML = `
      <div class="lifecycle-demo">
        <p>I am now connected to the document</p>
      </div>
    `;
  }

  disconnectedCallback() {
    console.log('disconnectedCallback: element removed from DOM');
    // Clean up event listeners, cancel requests, stop timers
    window.removeEventListener('resize', this._onResize);
  }

  adoptedCallback() {
    console.log('adoptedCallback: element moved to a new document');
    // Called when element moves between documents (e.g., with iframes)
  }

  attributeChangedCallback(name, oldValue, newValue) {
    console.log(`Attribute ${name} changed from ${oldValue} to ${newValue}`);
    // React to attribute changes — re-render or update state
  }

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

  _onResize() {
    // Handle resize
  }
}

customElements.define('lifecycle-demo', LifecycleDemo);

Key rules to remember:

Attributes and Properties

Custom elements shine when they expose a clear attribute-to-property contract. Attributes are the public API declared in HTML markup; properties are the programmatic interface accessed from JavaScript. Keeping them synchronized avoids confusion.

class StatusBadge extends HTMLElement {
  // Observe these attributes
  static get observedAttributes() {
    return ['status', 'label'];
  }

  constructor() {
    super();
    this._status = 'active';
    this._label = '';
  }

  connectedCallback() {
    this._render();
  }

  attributeChangedCallback(name, oldValue, newValue) {
    // Sync property when attribute changes
    if (name === 'status') {
      this._status = newValue || 'active';
    } else if (name === 'label') {
      this._label = newValue || '';
    }
    this._render();
  }

  // Property getters/setters keep the DOM attribute in sync
  get status() {
    return this._status;
  }

  set status(value) {
    this._status = value;
    // Reflect property change back to the attribute
    if (value) {
      this.setAttribute('status', value);
    } else {
      this.removeAttribute('status');
    }
    this._render();
  }

  get label() {
    return this._label;
  }

  set label(value) {
    this._label = value;
    if (value) {
      this.setAttribute('label', value);
    } else {
      this.removeAttribute('label');
    }
    this._render();
  }

  _render() {
    const colors = {
      active: '#2ecc71',
      pending: '#f39c12',
      error: '#e74c3c',
    };
    const color = colors[this._status] || '#95a5a6';

    this.innerHTML = `
      <span class="badge" style="
        display: inline-block;
        padding: 4px 12px;
        border-radius: 12px;
        background: ${color};
        color: white;
        font-size: 0.85rem;
        font-family: sans-serif;
      ">
        ${this._label || this._status}
      </span>
    `;
  }
}

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

Now you can use it from HTML or JavaScript:

<status-badge status="active" label="Deployed"></status-badge>
<status-badge status="pending" label="Building"></status-badge>

<script>
  const badge = document.querySelector('status-badge');
  badge.status = 'error';   // triggers re-render and updates the attribute
  console.log(badge.getAttribute('status')); // 'error'
</script>

The reflection pattern — where property setters update attributes and attribute changes update properties — ensures the element's state is always consistent regardless of how consumers interact with it.

Shadow DOM Integration

Custom elements become truly powerful when paired with Shadow DOM. The shadow root provides a scoped DOM subtree with isolated styles, preventing CSS collisions and keeping internal structure hidden from external selectors.

Building a Card Component with Shadow DOM

class InfoCard extends HTMLElement {
  constructor() {
    super();
    // Create a shadow root in "open" mode (accessible via element.shadowRoot)
    this.attachShadow({ mode: 'open' });
  }

  static get observedAttributes() {
    return ['heading', 'body-text', 'image-url'];
  }

  connectedCallback() {
    // Build the shadow DOM structure once
    this._render();
  }

  attributeChangedCallback(name, oldValue, newValue) {
    // Re-render when any observed attribute changes
    this._render();
  }

  _render() {
    const heading = this.getAttribute('heading') || 'Untitled';
    const bodyText = this.getAttribute('body-text') || '';
    const imageUrl = this.getAttribute('image-url') || '';

    // Styles defined here only apply inside the shadow root
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          max-width: 320px;
          border-radius: 12px;
          overflow: hidden;
          box-shadow: 0 2px 12px rgba(0,0,0,0.12);
          font-family: system-ui, sans-serif;
          background: #fff;
          margin: 1rem 0;
        }
        :host(:hover) {
          box-shadow: 0 4px 20px rgba(0,0,0,0.18);
          transform: translateY(-2px);
          transition: all 0.2s ease;
        }
        .card-image {
          width: 100%;
          height: 180px;
          object-fit: cover;
          display: ${imageUrl ? 'block' : 'none'};
        }
        .card-body {
          padding: 1.25rem;
        }
        .card-heading {
          margin: 0 0 0.5rem;
          font-size: 1.2rem;
          color: #1a1a1a;
        }
        .card-text {
          margin: 0;
          color: #555;
          line-height: 1.5;
          font-size: 0.95rem;
        }
      </style>
      ${imageUrl ? `${heading}` : ''}
      <div class="card-body">
        <h3 class="card-heading">${heading}</h3>
        <p class="card-text">${bodyText}</p>
      </div>
    `;
  }
}

customElements.define('info-card', InfoCard);

Usage in HTML:

<info-card
  heading="Mountain Sunrise"
  body-text="A breathtaking view of alpine peaks at dawn, with golden light spilling over the ridges."
  image-url="https://images.unsplash.com/photo-1506905921-5b4b0b0b0b0b?w=600"
></info-card>

The :host pseudo-class targets the custom element itself from inside the shadow root. Styles inside the shadow DOM never leak out, and external styles never pierce in — except for inheritable properties like font-family and color, which cascade through by design.

Slots and Composition

Shadow DOM's <slot> mechanism allows custom elements to accept and project arbitrary DOM content, creating a compositional API similar to what you'd expect from a framework component. Slots bridge the gap between the shadow boundary and the light DOM.

A Dialog Component Using Slots

class ModalDialog extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this._render();
    this._setupEvents();
  }

  disconnectedCallback() {
    this._cleanupEvents();
  }

  _render() {
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: none;
          position: fixed;
          inset: 0;
          z-index: 1000;
        }
        :host([open]) {
          display: flex;
          align-items: center;
          justify-content: center;
        }
        .overlay {
          position: absolute;
          inset: 0;
          background: rgba(0,0,0,0.5);
        }
        .dialog {
          position: relative;
          background: white;
          border-radius: 12px;
          max-width: 480px;
          width: 90%;
          padding: 0;
          box-shadow: 0 8px 30px rgba(0,0,0,0.25);
        }
        .header {
          padding: 1.25rem 1.5rem;
          border-bottom: 1px solid #eee;
          display: flex;
          justify-content: space-between;
          align-items: center;
        }
        .header h2 {
          margin: 0;
          font-size: 1.15rem;
        }
        .close-btn {
          background: none;
          border: none;
          font-size: 1.5rem;
          cursor: pointer;
          padding: 0;
          color: #666;
        }
        .body {
          padding: 1.25rem 1.5rem;
        }
        .footer {
          padding: 1rem 1.5rem;
          border-top: 1px solid #eee;
          display: flex;
          justify-content: flex-end;
          gap: 0.5rem;
        }
      </style>
      <div class="overlay"></div>
      <div class="dialog" role="dialog" aria-modal="true">
        <div class="header">
          <h2><slot name="title">Dialog</slot></h2>
          <button class="close-btn" aria-label="Close">×</button>
        </div>
        <div class="body">
          <slot>Default body content goes here</slot>
        </div>
        <div class="footer">
          <slot name="actions"></slot>
        </div>
      </div>
    `;
  }

  _setupEvents() {
    this._closeHandler = () => this.close();
    this._keyHandler = (e) => {
      if (e.key === 'Escape') this.close();
    };
    const closeBtn = this.shadowRoot.querySelector('.close-btn');
    const overlay = this.shadowRoot.querySelector('.overlay');
    closeBtn.addEventListener('click', this._closeHandler);
    overlay.addEventListener('click', this._closeHandler);
    document.addEventListener('keydown', this._keyHandler);
  }

  _cleanupEvents() {
    document.removeEventListener('keydown', this._keyHandler);
  }

  open() {
    this.setAttribute('open', '');
  }

  close() {
    this.removeAttribute('open');
  }

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

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'open') {
      // Dispatch a custom event so consumers can react
      this.dispatchEvent(new CustomEvent(
        newValue !== null ? 'dialog-opened' : 'dialog-closed',
        { bubbles: true, composed: true }
      ));
    }
  }
}

customElements.define('modal-dialog', ModalDialog);

Using named slots for structured composition:

<modal-dialog id="confirm-dialog">
  <h2 slot="title">Confirm Deletion</h2>
  <p>Are you sure you want to permanently delete this item? This action cannot be undone.</p>
  <div slot="actions">
    <button class="btn-secondary">Cancel</button>
    <button class="btn-danger">Delete</button>
  </div>
</modal-dialog>

<script>
  const dialog = document.querySelector('#confirm-dialog');
  dialog.addEventListener('dialog-opened', () => console.log('Dialog opened'));
  dialog.addEventListener('dialog-closed', () => console.log('Dialog closed'));
  dialog.open(); // Programmatically open
</script>

Slots with name attributes match content by slot attribute. The unnamed <slot> acts as a catch-all for any light DOM content not assigned to a named slot. This pattern lets consumers compose rich, semantic HTML inside your component.

Real-World Example: A Tab Container

Let's build a complete, production-style custom element that ties together attributes, shadow DOM, slots, and lifecycle management into a cohesive tab interface.

class TabContainer extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._tabs = [];
    this._panels = [];
    this._activeIndex = 0;
  }

  static get observedAttributes() {
    return ['active-tab'];
  }

  connectedCallback() {
    // Parse light DOM for tabs and panels
    this._parseStructure();
    this._render();
    this._addEventListeners();
  }

  disconnectedCallback() {
    this._removeEventListeners();
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'active-tab' && oldValue !== newValue) {
      const index = parseInt(newValue, 10);
      if (!isNaN(index) && index >= 0 && index < this._tabs.length) {
        this._activateTab(index);
      }
    }
  }

  _parseStructure() {
    // Children with slot="tab" become tabs
    // Children with slot="panel" become panels
    const tabElements = this.querySelectorAll('[slot="tab"]');
    const panelElements = this.querySelectorAll('[slot="panel"]');

    this._tabs = Array.from(tabElements).map(el => ({
      label: el.textContent.trim(),
      disabled: el.hasAttribute('disabled'),
    }));

    this._panels = Array.from(panelElements).map(el => ({
      content: el.innerHTML,
    }));
  }

  _render() {
    const tabButtons = this._tabs.map((tab, i) => {
      const isActive = i === this._activeIndex;
      const disabledAttr = tab.disabled ? 'disabled' : '';
      return `
        <button
          class="tab-btn ${isActive ? 'active' : ''}"
          role="tab"
          aria-selected="${isActive}"
          data-index="${i}"
          ${disabledAttr}
        >${tab.label}</button>
      `;
    }).join('');

    const panels = this._panels.map((panel, i) => {
      const isActive = i === this._activeIndex;
      return `
        <div
          class="panel ${isActive ? 'active' : ''}"
          role="tabpanel"
          aria-hidden="${!isActive}"
        >${panel.content}</div>
      `;
    }).join('');

    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          font-family: system-ui, sans-serif;
          border: 1px solid #e0e0e0;
          border-radius: 8px;
          overflow: hidden;
        }
        .tab-list {
          display: flex;
          background: #f5f5f5;
          border-bottom: 1px solid #e0e0e0;
          padding: 0;
        }
        .tab-btn {
          padding: 0.75rem 1.25rem;
          border: none;
          background: none;
          cursor: pointer;
          font-size: 0.95rem;
          color: #555;
          border-bottom: 2px solid transparent;
          transition: color 0.15s, border-color 0.15s;
          outline: none;
        }
        .tab-btn:hover:not(:disabled) {
          color: #1a1a1a;
          background: #ebebeb;
        }
        .tab-btn.active {
          color: #4a90d9;
          border-bottom-color: #4a90d9;
          font-weight: 600;
        }
        .tab-btn:disabled {
          opacity: 0.4;
          cursor: not-allowed;
        }
        .panel {
          display: none;
          padding: 1.5rem;
          line-height: 1.6;
          color: #333;
        }
        .panel.active {
          display: block;
        }
      </style>
      <div class="tab-list" role="tablist">${tabButtons}</div>
      <div class="panels-container">${panels}</div>
    `;
  }

  _activateTab(index) {
    if (index < 0 || index >= this._tabs.length) return;
    if (this._tabs[index].disabled) return;

    this._activeIndex = index;
    this.setAttribute('active-tab', String(index));

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

    // Update panel visibility
    const panels = this.shadowRoot.querySelectorAll('.panel');
    panels.forEach((panel, i) => {
      panel.classList.toggle('active', i === index);
      panel.setAttribute('aria-hidden', String(i !== index));
    });

    // Dispatch event for consumers
    this.dispatchEvent(new CustomEvent('tab-change', {
      detail: { index, label: this._tabs[index].label },
      bubbles: true,
      composed: true,
    }));
  }

  _addEventListeners() {
    this._clickHandler = (e) => {
      const btn = e.target.closest('.tab-btn');
      if (!btn) return;
      const index = parseInt(btn.dataset.index, 10);
      this._activateTab(index);
    };
    this.shadowRoot.addEventListener('click', this._clickHandler);
  }

  _removeEventListeners() {
    this.shadowRoot.removeEventListener('click', this._clickHandler);
  }

  // Public API
  selectTab(index) {
    this._activateTab(index);
  }

  get activeTab() {
    return this._activeIndex;
  }
}

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

Usage with light DOM composition:

<tab-container>
  <span slot="tab">Overview</span>
  <span slot="tab">Features</span>
  <span slot="tab" disabled>Admin (coming soon)</span>

  <div slot="panel">
    <h3>Project Overview</h3>
    <p>This dashboard shows key metrics and recent activity across all your projects.</p>
  </div>
  <div slot="panel">
    <h3>Key Features</h3>
    <ul>
      <li>Real-time collaboration</li>
      <li>Automated workflows</li>
      <li>Custom reporting</li>
    </ul>
  </div>
  <div slot="panel">
    <p>Admin panel access requires elevated permissions.</p>
  </div>
</tab-container>

<script>
  const tabs = document.querySelector('tab-container');
  tabs.addEventListener('tab-change', (e) => {
    console.log(`Switched to tab: ${e.detail.label} (index ${e.detail.index})`);
  });
  // Programmatic control
  setTimeout(() => tabs.selectTab(1), 2000);
</script>

Best Practices

Building custom elements that are robust, accessible, and maintainable requires more than just wiring up a class. Follow these guidelines to avoid common pitfalls:

Testing Custom Elements

Testing custom elements is straightforward because they're just DOM nodes. Use any testing library that can manipulate the DOM — jsdom, Testing Library, or even plain assertions in a browser environment.

// Example test using jsdom and vitest
import { describe, it, expect } from 'vitest';

describe('status-badge', () => {
  it('renders with default status', () => {
    document.body.innerHTML = '<status-badge></status-badge>';
    const badge = document.querySelector('status-badge');
    // Wait for connectedCallback
    return new Promise(resolve => {
      setTimeout(() => {
        expect(badge.querySelector('.badge')).toBeTruthy();
        expect(badge.querySelector('.badge').textContent).toContain('active');
        resolve();
      }, 0);
    });
  });

  it('reflects status property to attribute', () => {
    const badge = document.createElement('status-badge');
    document.body.appendChild(badge);
    badge.status = 'error';
    expect(badge.getAttribute('status')).toBe('error');
    badge.remove();
  });

  it('dispatches tab-change event', () => {
    document.body.innerHTML = `
      <tab-container>
        <span slot="tab">First</span>
        <span slot="tab">Second</span>
        <div slot="panel">Content 1</div>
        <div slot="panel">Content 2</div>
      </tab-container>
    `;
    const container = document.querySelector('tab-container');
    let eventDetail = null;
    container.addEventListener('tab-change', (e) => {
      eventDetail = e.detail;
    });
    container.selectTab(1);
    expect(eventDetail.index).toBe(1);
    expect(eventDetail.label).toBe('Second');
  });
});

Conclusion

HTML custom elements represent a fundamental shift in how we think about UI composition on the web. They bring the component model directly into the browser, untethered from any particular framework or build pipeline. By mastering the lifecycle callbacks, attribute reflection, Shadow DOM encapsulation, and slot-based composition, you gain the ability to build truly portable, future-proof interface primitives. The patterns shown here — from simple text banners to full tab containers with ARIA support — form the foundation for a library of reusable elements that will work identically across projects, teams, and years. Start small, encapsulate thoughtfully, and let the web platform carry your components forward.

🚀 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