Web Components: Custom Elements and Shadow DOM

Web Components represent the browser's native solution to component-based architecture. Unlike framework-specific components, Web Components are built on standardized APIs that work everywhere—React,...

Key Insights

  • Web Components provide framework-agnostic, reusable UI elements using browser-native APIs—no build tools or libraries required
  • Shadow DOM delivers true style and markup encapsulation, preventing CSS conflicts and creating predictable component boundaries
  • Custom Elements with lifecycle callbacks enable reactive, self-contained components that work seamlessly with any JavaScript framework or vanilla JS

Understanding Web Components

Web Components represent the browser’s native solution to component-based architecture. Unlike framework-specific components, Web Components are built on standardized APIs that work everywhere—React, Vue, Angular, or plain HTML. The specification comprises three core technologies: Custom Elements for defining new HTML tags, Shadow DOM for encapsulation, and HTML Templates for reusable markup patterns.

The value proposition is compelling: write a component once, use it anywhere. No transpilation, no framework lock-in, no dependency hell. Modern browsers support Web Components natively, making them a practical choice for design systems, widget libraries, and micro-frontends.

Creating Custom Elements

Custom Elements let you define your own HTML tags with custom behavior. The customElements.define() method registers a new element, associating a tag name with a JavaScript class that extends HTMLElement.

Here’s a basic user card component:

class UserCard extends HTMLElement {
  constructor() {
    super();
    this.userName = 'Anonymous';
    this.userRole = 'Guest';
  }

  connectedCallback() {
    this.render();
  }

  render() {
    this.innerHTML = `
      <div class="user-card">
        <h3>${this.userName}</h3>
        <p>${this.userRole}</p>
      </div>
    `;
  }
}

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

Now you can use <user-card></user-card> in your HTML like any standard element. The connectedCallback() lifecycle method fires when the element is inserted into the DOM—perfect for initialization and rendering.

Key lifecycle callbacks include:

  • connectedCallback(): Element added to the DOM
  • disconnectedCallback(): Element removed from the DOM
  • attributeChangedCallback(name, oldValue, newValue): Observed attribute changes
  • adoptedCallback(): Element moved to a new document

Custom elements must contain a hyphen in their name (e.g., user-card, not usercard) to distinguish them from standard HTML elements and avoid future naming conflicts.

Shadow DOM Fundamentals

Shadow DOM provides true encapsulation by creating a separate DOM tree attached to your element. Styles inside the shadow tree don’t leak out, and external styles don’t leak in—solving CSS specificity nightmares.

Let’s add Shadow DOM to our user card:

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

  connectedCallback() {
    this.render();
  }

  render() {
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          border: 2px solid #333;
          border-radius: 8px;
          padding: 16px;
          max-width: 300px;
        }

        .user-card {
          font-family: system-ui, sans-serif;
        }

        h3 {
          margin: 0 0 8px 0;
          color: #2563eb;
        }

        p {
          margin: 0;
          color: #666;
        }
      </style>
      <div class="user-card">
        <h3>${this.userName}</h3>
        <p>${this.userRole}</p>
      </div>
    `;
  }
}

The attachShadow({ mode: 'open' }) creates a shadow root. The open mode allows external JavaScript to access the shadow DOM via element.shadowRoot. Use closed mode to prevent external access, though this is rarely necessary and limits debugging.

The :host selector targets the custom element itself from within the shadow DOM. This is where you define the component’s outer container styles. The styles inside are completely isolated—that h3 selector won’t affect any other h3 elements on the page.

Attributes and Properties

Custom elements should accept configuration through HTML attributes, just like native elements. The observedAttributes static getter defines which attributes trigger the attributeChangedCallback():

class UserCard extends HTMLElement {
  static get observedAttributes() {
    return ['name', 'role', 'avatar'];
  }

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

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

  connectedCallback() {
    this.render();
  }

  render() {
    const name = this.getAttribute('name') || 'Anonymous';
    const role = this.getAttribute('role') || 'Guest';
    const avatar = this.getAttribute('avatar') || '';

    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          border: 2px solid #333;
          border-radius: 8px;
          padding: 16px;
          max-width: 300px;
        }

        .avatar {
          width: 64px;
          height: 64px;
          border-radius: 50%;
          object-fit: cover;
          margin-bottom: 12px;
        }

        h3 {
          margin: 0 0 4px 0;
          color: #2563eb;
        }

        p {
          margin: 0;
          color: #666;
        }
      </style>
      <div class="user-card">
        ${avatar ? `<img class="avatar" src="${avatar}" alt="${name}">` : ''}
        <h3>${name}</h3>
        <p>${role}</p>
      </div>
    `;
  }
}

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

Usage:

<user-card 
  name="Sarah Chen" 
  role="Senior Developer"
  avatar="/images/sarah.jpg">
</user-card>

For better API design, consider reflecting attributes as properties:

get name() {
  return this.getAttribute('name');
}

set name(value) {
  this.setAttribute('name', value);
}

This enables both declarative (<user-card name="Sarah">) and imperative (userCard.name = 'Sarah') APIs.

Slots and Content Projection

Slots enable flexible content composition. Instead of hardcoding all markup, slots let consumers provide their own content while maintaining your component’s structure and styling:

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

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          border: 2px solid #333;
          border-radius: 8px;
          padding: 16px;
          max-width: 300px;
        }

        .avatar-slot {
          display: block;
          margin-bottom: 12px;
        }

        ::slotted(img) {
          width: 64px;
          height: 64px;
          border-radius: 50%;
          object-fit: cover;
        }

        h3 {
          margin: 0 0 4px 0;
          color: #2563eb;
        }

        .bio {
          margin-top: 8px;
          font-size: 14px;
          color: #666;
        }
      </style>
      <div class="user-card">
        <div class="avatar-slot">
          <slot name="avatar"></slot>
        </div>
        <h3><slot name="name">Anonymous</slot></h3>
        <p><slot name="role">Guest</slot></p>
        <div class="bio">
          <slot name="bio"></slot>
        </div>
      </div>
    `;
  }
}

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

Usage with named slots:

<user-card>
  <img slot="avatar" src="/images/sarah.jpg" alt="Sarah Chen">
  <span slot="name">Sarah Chen</span>
  <span slot="role">Senior Developer</span>
  <p slot="bio">Passionate about Web Components and modern web standards.</p>
</user-card>

The ::slotted() pseudo-element styles slotted content from within the shadow DOM, though it can only target direct children of the slot.

Building a Tabbed Interface

Let’s build a complete tabbed interface demonstrating real-world Web Component patterns:

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

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
        }

        .tabs {
          display: flex;
          border-bottom: 2px solid #e5e7eb;
          gap: 4px;
        }

        ::slotted(tab-button) {
          padding: 12px 24px;
          border: none;
          background: transparent;
          cursor: pointer;
          border-bottom: 2px solid transparent;
          margin-bottom: -2px;
        }

        ::slotted(tab-button[active]) {
          border-bottom-color: #2563eb;
          color: #2563eb;
        }

        .panels {
          padding: 16px 0;
        }
      </style>
      <div class="tabs">
        <slot name="tabs"></slot>
      </div>
      <div class="panels">
        <slot name="panels"></slot>
      </div>
    `;

    this.addEventListener('tab-select', this.handleTabSelect.bind(this));
    this.selectTab(0);
  }

  handleTabSelect(event) {
    this.selectTab(event.detail.index);
  }

  selectTab(index) {
    const tabs = this.querySelectorAll('tab-button');
    const panels = this.querySelectorAll('tab-panel');

    tabs.forEach((tab, i) => {
      tab.toggleAttribute('active', i === index);
      tab.setAttribute('aria-selected', i === index);
    });

    panels.forEach((panel, i) => {
      panel.toggleAttribute('active', i === index);
    });
  }
}

class TabButton extends HTMLElement {
  connectedCallback() {
    this.setAttribute('role', 'tab');
    this.setAttribute('tabindex', '0');
    
    this.addEventListener('click', () => {
      const index = Array.from(this.parentElement.children).indexOf(this);
      this.dispatchEvent(new CustomEvent('tab-select', {
        bubbles: true,
        detail: { index }
      }));
    });

    this.addEventListener('keydown', (e) => {
      if (e.key === 'Enter' || e.key === ' ') {
        e.preventDefault();
        this.click();
      }
    });
  }
}

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

  connectedCallback() {
    this.setAttribute('role', 'tabpanel');
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: none;
        }

        :host([active]) {
          display: block;
        }
      </style>
      <slot></slot>
    `;
  }
}

customElements.define('tab-group', TabGroup);
customElements.define('tab-button', TabButton);
customElements.define('tab-panel', TabPanel);

Usage:

<tab-group>
  <tab-button slot="tabs">Profile</tab-button>
  <tab-button slot="tabs">Settings</tab-button>
  <tab-button slot="tabs">Privacy</tab-button>

  <tab-panel slot="panels">
    <h2>Profile Information</h2>
    <p>Manage your profile details.</p>
  </tab-panel>
  <tab-panel slot="panels">
    <h2>Settings</h2>
    <p>Configure your preferences.</p>
  </tab-panel>
  <tab-panel slot="panels">
    <h2>Privacy Controls</h2>
    <p>Manage your privacy settings.</p>
  </tab-panel>
</tab-group>

This example demonstrates component communication via custom events, accessibility with ARIA attributes and keyboard navigation, and state management across related components.

Browser Support and Best Practices

Web Components enjoy excellent browser support. All modern browsers (Chrome, Firefox, Safari, Edge) fully support Custom Elements and Shadow DOM. For older browsers, the @webcomponents/webcomponentsjs polyfill provides compatibility.

When to use Web Components:

  • Design systems shared across multiple frameworks
  • Third-party widgets and embeddable components
  • Micro-frontend architectures
  • Long-lived codebases where framework churn is a concern

When to consider alternatives:

  • Heavy server-side rendering requirements (though Declarative Shadow DOM helps)
  • Complex state management (combine with libraries like Redux)
  • Teams deeply invested in a specific framework’s ecosystem

Performance considerations:

  • Avoid excessive re-rendering by caching shadow DOM references
  • Use event delegation for dynamic content
  • Lazy-load components with dynamic imports
  • Consider using <template> elements for repeated structures

Web Components aren’t a silver bullet, but they’re a powerful, standards-based tool for building reusable UI components. Their framework-agnostic nature and native browser support make them increasingly relevant as web development matures beyond framework wars toward interoperability.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.