Skip to main content

Web Components Complete Guide: Native Reusable Elements in 2026

Created: March 7, 2026 CalmOps 12 min read

Introduction

Web Components represent the web platform’s native answer to component-based architecture. Rather than relying on framework-specific abstractions, Web Components leverage browser standards that work across any technology stack. This means the components you build today will work in React, Vue, Angular, or plain HTML—forever.

The technology has matured significantly since its inception. What began as a collection of experimental APIs has evolved into a stable foundation for building reusable UI elements. Browser support is now universal, and the ecosystem includes libraries, tooling, and patterns that make Web Components practical for production applications.

In 2026, Web Components have found their place in the web development ecosystem. They’re the common denominator—the glue that connects different frameworks and enables true code sharing across organizational boundaries. This comprehensive guide covers everything from fundamentals to advanced patterns, helping you build robust, reusable components.

Core Technologies

Web Components rest on three pillars: Custom Elements, Shadow DOM, and HTML Templates. Understanding these technologies is essential for effective component development.

Custom Elements

Custom Elements let developers define new HTML tags with custom behavior:

class MyElement extends HTMLElement {
  constructor() {
    super();
    // Initialize component
  }
  
  connectedCallback() {
    // Element added to DOM
  }
  
  disconnectedCallback() {
    // Element removed from DOM
  }
  
  attributeChangedCallback(name, oldValue, newValue) {
    // Attribute changed
  }
  
  static get observedAttributes() {
    return ['value', 'disabled'];
  }
}

// Register the custom element
customElements.define('my-element', MyElement);

Once registered, use the element like any HTML tag:

<my-element value="Hello"></my-element>

The browser handles instantiation, lifecycle management, and garbage collection automatically. Your class defines the behavior.

Shadow DOM

Shadow DOM provides encapsulation, isolating component styles and markup from the rest of the page:

class MyComponent extends HTMLElement {
  constructor() {
    super();
    
    // Attach shadow DOM
    const shadow = this.attachShadow({ mode: 'open' });
    
    // Add internal content
    shadow.innerHTML = `
      <style>
        :host {
          display: block;
          padding: 16px;
          background: #f5f5f5;
        }
        
        :host([hidden]) {
          display: none;
        }
        
        .content {
          color: #333;
        }
      </style>
      <div class="content">
        <slot></slot>
      </div>
    `;
  }
}

Styles defined inside the shadow tree don’t leak out, and page styles don’t penetrate in. This isolation is crucial for building reliable, reusable components.

HTML Templates

The <template> element defines reusable markup that isn’t rendered until activated:

<template id="card-template">
  <style>
    .card {
      border: 1px solid #ddd;
      border-radius: 8px;
      padding: 16px;
    }
  </style>
  <div class="card">
    <h2><slot name="title">Default Title</slot></h2>
    <p><slot>Default content</slot></p>
  </div>
</template>
class CardElement extends HTMLElement {
  constructor() {
    super();
    const template = document.getElementById('card-template');
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.appendChild(template.content.cloneNode(true));
  }
}

Templates are parsed once and reused across instances, making them memory-efficient.

Building Your First Component

Let’s build a practical component to understand the complete development workflow.

A Custom Button Component

Create a button component with built-in styling and behavior:

class ActionButton extends HTMLElement {
  static get observedAttributes() {
    return ['variant', 'disabled'];
  }
  
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    
    shadow.innerHTML = `
      <style>
        :host {
          display: inline-block;
        }
        
        :host([disabled]) {
          pointer-events: none;
          opacity: 0.6;
        }
        
        button {
          font-family: inherit;
          font-size: 14px;
          padding: 8px 16px;
          border: none;
          border-radius: 4px;
          cursor: pointer;
          transition: background-color 0.2s, transform 0.1s;
        }
        
        button:active {
          transform: scale(0.98);
        }
        
        :host([variant="primary"]) button {
          background: #0066cc;
          color: white;
        }
        
        :host([variant="primary"]) button:hover {
          background: #0055aa;
        }
        
        :host([variant="danger"]) button {
          background: #dc3545;
          color: white;
        }
        
        :host([variant="danger"]) button:hover {
          background: #c82333;
        }
        
        :host(:not([variant])) button {
          background: #6c757d;
          color: white;
        }
        
        :host(:not([variant])) button:hover {
          background: #5a6268;
        }
      </style>
      
      <button type="button">
        <slot></slot>
      </button>
    `;
    
    this.button = shadow.querySelector('button');
  }
  
  connectedCallback() {
    this.button.addEventListener('click', this._handleClick);
  }
  
  disconnectedCallback() {
    this.button.removeEventListener('click', this._handleClick);
  }
  
  _handleClick = (event) => {
    if (this.disabled) return;
    
    this.dispatchEvent(new CustomEvent('action', {
      bubbles: true,
      composed: true,
      detail: { originalEvent: event }
    }));
  }
  
  get disabled() {
    return this.hasAttribute('disabled');
  }
  
  set disabled(value) {
    if (value) {
      this.setAttribute('disabled', '');
    } else {
      this.removeAttribute('disabled');
    }
  }
  
  get variant() {
    return this.getAttribute('variant') || 'default';
  }
  
  set variant(value) {
    this.setAttribute('variant', value);
  }
}

customElements.define('action-button', ActionButton);

Use the component:

<action-button variant="primary" id="submit-btn">
  Submit Form
</action-button>

<script>
  document.getElementById('submit-btn')
    .addEventListener('action', (e) => console.log('Clicked!'));
</script>

This component encapsulates styling, handles events, exposes properties, and communicates through standard DOM mechanisms.

Component Lifecycle

Understanding lifecycle callbacks helps manage resources and maintain correctness.

Lifecycle Overview

The custom elements specification defines several lifecycle callbacks:

class LifecycleComponent extends HTMLElement {
  constructor() {
    super();
    console.log('1. Constructor - element created');
  }
  
  connectedCallback() {
    console.log('2. Connected - added to DOM');
  }
  
  disconnectedCallback() {
    console.log('3. Disconnected - removed from DOM');
  }
  
  attributeChangedCallback(name, oldValue, newValue) {
    console.log(`4. Attribute ${name} changed from ${oldValue} to ${newValue}`);
  }
  
  adoptedCallback() {
    console.log('5. Adopted - moved to new document');
  }
}

Use these callbacks to manage subscriptions, set up observers, and clean up resources.

Timing Considerations

Lifecycle callbacks fire at specific times:

class AsyncComponent extends HTMLElement {
  async connectedCallback() {
    // DOM is available but might not be fully rendered
    await this.updateComplete;
    
    // Element is fully rendered
    this._setup();
  }
  
  get updateComplete() {
    return this._updating ? Promise.resolve() : Promise.resolve(true);
  }
}

The updateComplete promise resolves after all synchronous operations finish, useful for testing and coordination.

Cleanup

Always clean up in disconnectedCallback:

class CleanupComponent extends HTMLElement {
  connectedCallback() {
    this._observer = new MutationObserver(this._handleMutations);
    this._observer.observe(this, { attributes: true });
    
    this._clickHandler = (e) => this._handleClick(e);
    document.addEventListener('click', this._clickHandler);
    
    this._interval = setInterval(this._tick, 1000);
  }
  
  disconnectedCallback() {
    // Clean up everything
    this._observer.disconnect();
    document.removeEventListener('click', this._clickHandler);
    clearInterval(this._interval);
  }
}

Failing to clean up causes memory leaks and unexpected behavior when components are removed and re-added.

Properties and Attributes

Managing the relationship between JavaScript properties and HTML attributes is crucial for useful components.

Reflecting Properties to Attributes

Changes to JavaScript properties should update attributes for consistency:

class ToggleSwitch extends HTMLElement {
  static get observedAttributes() { return ['checked', 'disabled']; }
  
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._render();
  }
  
  _render() {
    this.shadowRoot.innerHTML = `
      <style>
        :host { display: inline-block; }
        .switch {
          width: 40px;
          height: 22px;
          background: #ccc;
          border-radius: 11px;
          position: relative;
          cursor: pointer;
        }
        .switch[checked] {
          background: #4caf50;
        }
        .knob {
          width: 18px;
          height: 18px;
          background: white;
          border-radius: 50%;
          position: absolute;
          top: 2px;
          left: 2px;
          transition: transform 0.2s;
        }
        .switch[checked] .knob {
          transform: translateX(18px);
        }
      </style>
      <div class="switch" role="switch" aria-checked="false">
        <div class="knob"></div>
      </div>
    `;
    
    this._switch = this.shadowRoot.querySelector('.switch');
    this._switch.addEventListener('click', () => this.toggle());
  }
  
  get checked() { return this.hasAttribute('checked'); }
  
  set checked(value) {
    const isChecked = Boolean(value);
    if (isChecked) {
      this.setAttribute('checked', '');
    } else {
      this.removeAttribute('checked');
    }
  }
  
  toggle() {
    this.checked = !this.checked;
  }
  
  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'checked') {
      if (newValue !== null) {
        this._switch.setAttribute('checked', '');
        this._switch.setAttribute('aria-checked', 'true');
      } else {
        this._switch.removeAttribute('checked');
        this._switch.setAttribute('aria-checked', 'false');
      }
    }
  }
}

The property setter reflects to the attribute, and attributeChangedCallback updates the internal DOM.

Complex Property Types

Handle non-string properties carefully:

class DataTable extends HTMLElement {
  static get observedAttributes() { return ['data']; }
  
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._data = [];
  }
  
  // Use a getter/setter pair for objects
  get data() { return this._data; }
  
  set data(value) {
    this._data = Array.isArray(value) ? value : [];
    this._render();
  }
  
  // Attributes contain JSON
  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'data' && oldValue !== newValue) {
      try {
        this._data = JSON.parse(newValue);
        this._render();
      } catch (e) {
        console.error('Invalid JSON in data attribute');
      }
    }
  }
  
  _render() {
    // Render table from this._data
  }
}

This pattern allows both attribute-based and property-based usage:

<data-table data='[{"name": "Alice"}]'></data-table>
const table = document.querySelector('data-table');
table.data = [{ name: 'Alice' }, { name: 'Bob' }];

Slots and Composition

Slots enable flexible component composition, allowing users to inject content into your components.

Named Slots

Multiple slots provide flexible content areas:

class CardComponent extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          border: 1px solid #ddd;
          border-radius: 8px;
          overflow: hidden;
        }
        
        .header {
          background: #f5f5f5;
          padding: 12px 16px;
          border-bottom: 1px solid #ddd;
        }
        
        .body {
          padding: 16px;
        }
        
        .footer {
          padding: 12px 16px;
          border-top: 1px solid #ddd;
          background: #fafafa;
        }
        
        slot[name="header"]::slotted(h2) {
          margin: 0;
          font-size: 1.25rem;
        }
      </style>
      
      <div class="header">
        <slot name="header">
          <h2>Default Title</h2>
        </slot>
      </div>
      
      <div class="body">
        <slot></slot>
      </div>
      
      <div class="footer">
        <slot name="footer"></slot>
      </div>
    `;
  }
}

Usage:

<card-component>
  <h2 slot="header">My Card</h2>
  
  <p>This is the main content.</p>
  <p>It can contain any elements.</p>
  
  <button slot="footer">Action</button>
</card-component>

Default Content

Slots provide default content when nothing is provided:

class AlertBox extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        :host { display: block; }
        .alert {
          padding: 12px 16px;
          border-radius: 4px;
        }
        :host([type="info"]) .alert { background: #e3f2fd; color: #0d47a1; }
        :host([type="warning"]) .alert { background: #fff3e0; color: #e65100; }
        :host([type="error"]) .alert { background: #ffebee; color: #b71c1c; }
      </style>
      <div class="alert">
        <slot>
          <strong>Notice:</strong> Default alert message.
        </slot>
      </div>
    `;
  }
}

Slot Events

Listen for slot changes:

class SlotWatcher extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <slot></slot>
    `;
  }
  
  connectedCallback() {
    const slot = this.shadowRoot.querySelector('slot');
    slot.addEventListener('slotchange', (e) => {
      const nodes = e.target.assignedElements();
      console.log(`Slot changed, now has ${nodes.length} elements`);
    });
  }
}

The slotchange event fires when projected content changes.

Styling Components

Styling Web Components requires understanding the interaction between shadow and light DOM styles.

CSS Custom Properties

Pass styles through with CSS variables:

class ThemedButton extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        button {
          background: var(--btn-bg, #0066cc);
          color: var(--btn-color, white);
          border: var(--btn-border, none);
          padding: var(--btn-padding, 8px 16px);
          border-radius: var(--btn-radius, 4px);
          font-size: var(--btn-font-size, 14px);
        }
      </style>
      <button><slot></slot></button>
    `;
  }
}
<themed-button>Default</themed-button>

<themed-button style="
  --btn-bg: #28a745;
  --btn-radius: 8px;
">
  Custom Theme
</themed-button>

CSS custom properties pierce shadow DOM, making them perfect for theming.

:host Selectors

The :host pseudo-class targets the component element itself:

class PositionedElement extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          position: absolute;
        }
        
        :host([position="top"]) { top: 0; }
        :host([position="bottom"]) { bottom: 0; }
        :host([position="left"]) { left: 0; }
        :host([position="right"]) { right: 0; }
        
        :host([hidden]) {
          display: none;
        }
      </style>
      <slot></slot>
    `;
  }
}

Other host selectors include :host(), :host-context() for parent-based styling.

Constructable Stylesheets

Share styles efficiently with constructable stylesheets:

const sharedStyles = new CSSStyleSheet();
sharedStyles.replace(`
  .common {
    font-family: system-ui, sans-serif;
    line-height: 1.5;
  }
  
  .highlight {
    background: yellow;
  }
`);

class StyledComponent extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.adoptedStyleSheets = [sharedStyles];
    shadow.innerHTML = '<div class="common highlight">Content</div>';
  }
}

Shared stylesheets reduce memory usage when components appear multiple times.

Form Participation

Making components participate in forms requires specific techniques.

Native Form Integration

Custom elements can participate in forms natively:

class FancyInput extends HTMLElement {
  static get formAssociated() { return true; }
  
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._internals = this.attachInternals();
    
    this.shadowRoot.innerHTML = `
      <style>
        :host { display: block; }
        input {
          padding: 8px;
          border: 1px solid #ccc;
          border-radius: 4px;
        }
      </style>
      <input type="text" id="input">
    `;
    
    this._input = this.shadowRoot.getElementById('input');
    this._input.addEventListener('input', () => {
      this._internals.setFormValue(this._input.value);
    });
  }
  
  get value() { return this._input.value; }
  set value(v) { this._input.value = v; }
  
  get validity() { return this._internals.validity; }
  get validationMessage() { return this._internals.validationMessage; }
  get willValidate() { return this._internals.willValidate; }
  
  checkValidity() { return this._internals.checkValidity(); }
  reportValidity() { return this._internals.reportValidity(); }
}

The attachInternals() method enables form participation with validation, error states, and disabled behavior.

Form Reset

Handle form reset:

class FancyInput extends HTMLElement {
  // ... previous code ...
  
  formResetCallback() {
    this._input.value = '';
    this._internals.setFormValue('');
  }
}

The formResetCallback fires when the parent form resets.

Advanced Patterns

Beyond basics, advanced patterns enable sophisticated components.

Reactive Components

Build reactive state management:

class ReactiveCounter extends HTMLElement {
  #state = { count: 0 };
  
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._render();
  }
  
  get count() { return this.#state.count; }
  
  set count(value) {
    this.#state.count = value;
    this._render();
  }
  
  increment() {
    this.count++;
  }
  
  _render() {
    this.shadowRoot.innerHTML = `
      <style>
        button { padding: 8px 16px; }
      </style>
      <div>
        Count: ${this.#state.count}
        <button id="inc">+</button>
      </div>
    `;
    
    this.shadowRoot.getElementById('inc')
      .addEventListener('click', () => this.increment());
  }
}

For more complex reactivity, consider reactive libraries like @lit-labs/react or build your own.

Intersection Observer

Detect visibility:

class LazyImage extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        :host { display: block; }
        img { max-width: 100%; }
      </style>
      <img id="img" loading="lazy">
    `;
  }
  
  connectedCallback() {
    this._observer = new IntersectionObserver(entries => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          this._loadImage();
          this._observer.disconnect();
        }
      });
    });
    
    this._observer.observe(this);
  }
  
  disconnectedCallback() {
    this._observer.disconnect();
  }
  
  _loadImage() {
    const src = this.getAttribute('src');
    if (src) {
      this.shadowRoot.getElementById('img').src = src;
    }
  }
}

ResizeObserver

Respond to size changes:

class ResponsiveContainer extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <slot></slot>
    `;
  }
  
  connectedCallback() {
    this._resizeObserver = new ResizeObserver(entries => {
      entries.forEach(entry => {
        this.dispatchEvent(new CustomEvent('resize', {
          detail: {
            width: entry.contentRect.width,
            height: entry.contentRect.height
          }
        }));
      });
    });
    
    this._resizeObserver.observe(this);
  }
  
  disconnectedCallback() {
    this._resizeObserver.disconnect();
  }
}

Using with Frameworks

Web Components work alongside any framework.

React Integration

React can render Web Components with special handling:

// For components with properties
<FancyInput 
  ref={inputRef}
  value={state}
  onInput={(e) => setState(e.target.value)}
  // Use onChange for value changes
  onChange={(e) => setState(e.target.value)}
/>

// Register custom elements
import { useEffect } from 'react';

function useCustomElement(name, componentClass) {
  useEffect(() => {
    if (!customElements.get(name)) {
      customElements.define(name, componentClass);
    }
  }, [name, componentClass]);
}

Vue Integration

Vue handles Web Components seamlessly:

<template>
  <fancy-input 
    v-model="value"
    @action="handleAction"
  />
</template>

<script>
export default {
  data() {
    return { value: '' }
  },
  methods: {
    handleAction(e) {
      console.log('Action triggered', e);
    }
  }
}
</script>

Angular Integration

Angular requires schema configuration:

import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';

@NgModule({
  declarations: [MyComponent],
  imports: [CommonModule],
  schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class MyModule { }

Best Practices

Building production-ready components requires following established patterns.

Accessibility

Always prioritize accessibility:

class AccessibleMenu extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        :host { display: block; }
        [role="menu"] { border: 1px solid #ccc; }
        [role="menuitem"] { padding: 8px; }
        [role="menuitem"]:focus { background: #0066cc; color: white; }
      </style>
      <div role="menu" tabindex="0">
        <slot></slot>
      </div>
    `;
  }
  
  connectedCallback() {
    this.setAttribute('role', 'menubar');
    this.tabIndex = 0;
  }
}

Use ARIA roles, keyboard navigation, and focus management.

Performance

Optimize rendering:

class OptimizedComponent extends HTMLElement {
  #rendered = false;
  
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }
  
  connectedCallback() {
    if (!this.#rendered) {
      this._render();
      this.#rendered = true;
    }
  }
  
  // Use attributeChangedCallback for targeted updates
  attributeChangedCallback(name, old, value) {
    if (old !== value) {
      this._updateAttribute(name, value);
    }
  }
}

Documentation

Document your components:

/**
 * A button component with customizable variants.
 * 
 * @element fancy-button
 * 
 * @fires click - Fired when button is clicked
 * 
 * @slot - The button text
 * 
 * @cssprop --btn-bg - Background color
 * @cssprop --btn-color - Text color
 * 
 * @example
 * <fancy-button variant="primary">Click Me</fancy-button>
 */
class FancyButton extends HTMLElement { }

Use JSDoc comments that tools can consume.

Conclusion

Web Components represent the web platform’s commitment to native, standards-based component architecture. They provide a stable foundation for building reusable UI elements that transcend framework boundaries and stand the test of time.

The key to success lies in understanding the platform: embrace shadow DOM for encapsulation, use slots for flexibility, implement proper lifecycle management, and prioritize accessibility. With these fundamentals, you can build components that work anywhere and last indefinitely.

As frameworks continue to evolve and consolidate around web standards, Web Components become increasingly valuable. They represent the one investment in UI architecture that never needs to be rewritten. Build your components right, and they’ll serve your applications for years to come.

Resources

Comments

Share this article

Scan to read on mobile