MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Web Components: Building Framework-Agnostic UI

Create reusable Web Components: Shadow DOM, custom elements, and HTML templates.

Web ComponentsShadow DOMCustom ElementsFrontend

By MinhVo

Introduction

Web Components represent a paradigm shift in how we build user interfaces for the modern web. Unlike framework-specific solutions that lock you into React, Vue, or Angular ecosystems, Web Components provide a standardized, browser-native approach to creating reusable UI elements that work everywhere. Since their initial proposal in 2011 and widespread browser support by 2020, they have become a cornerstone of truly portable frontend development.

The promise of "write once, use anywhere" has been chased by many technologies, but Web Components deliver it through the browser's own APIs. By leveraging Custom Elements, Shadow DOM, and HTML Templates, developers can create encapsulated, reusable components that maintain their functionality regardless of the surrounding JavaScript framework—or lack thereof. This makes them invaluable for design systems, micro-frontend architectures, and long-lived applications where framework migrations are a reality.

In this comprehensive guide, we'll dive deep into the three pillars of Web Components, explore advanced patterns for real-world applications, and examine how they integrate with modern build tools and testing frameworks. Whether you're building a component library for multiple teams or seeking to future-proof your UI layer, understanding Web Components is essential for 2021 and beyond.

Web Components architecture diagram

Understanding Web Components: Core Concepts

Web Components are a suite of different technologies that allow you to create reusable custom elements—whose functionality is encapsulated away from the rest of the code—and utilize them in your web applications. The specification is maintained by the W3C and has achieved near-universal browser support.

The Three Pillars

The Web Components specification consists of three main technologies that work together:

Custom Elements provide the foundation by allowing developers to define new HTML tags with custom behavior. When you register a custom element with customElements.define(), the browser recognizes it as a legitimate HTML element, complete with lifecycle callbacks that mirror the natural lifecycle of DOM elements.

Shadow DOM delivers true encapsulation by creating an isolated DOM subtree attached to an element. This subtree is hidden from the main document's JavaScript and CSS, preventing style leakage and accidental DOM manipulation. It's the key to building components that don't break when used in hostile environments.

HTML Templates offer a mechanism for declaring inert HTML fragments that can be instantiated at runtime. The <template> and <slot> elements allow you to define the structure of your component's shadow tree without incurring the cost of parsing and rendering until the template is actually used.

The Evolution of Component Architecture

Before Web Components, developers relied on patterns like jQuery plugins, Angular directives, or React components—each tied to their respective framework. This created fragmentation: a date picker built for React couldn't be used in a Vue application without significant rewriting.

Web Components solve this by operating at the browser level. The browser itself becomes the runtime, eliminating the need for a framework to manage component lifecycle, rendering, or encapsulation. This doesn't mean frameworks are obsolete—rather, Web Components provide a common foundation that frameworks can build upon.

Browser Support and Polyfills

As of 2021, Custom Elements v1 and Shadow DOM v1 are supported in all major browsers: Chrome, Firefox, Safari, and Edge (Chromium-based). For legacy browser support (primarily Internet Explorer 11), the @webcomponents/webcomponentsjs polyfill provides the necessary shims, though with some performance overhead.

The template element enjoys even broader support, having been part of the HTML specification since 2014. This means the core Web Components API is production-ready without polyfills for the vast majority of users.

Browser compatibility dashboard

Architecture and Design Patterns

Building effective Web Components requires understanding several architectural patterns that differ from framework-based development. The key insight is that Web Components are fundamentally about encapsulation and composition—not about application state management.

Component Hierarchy and Composition

Web Components naturally form a tree hierarchy through the DOM. A parent component can contain child components, and communication flows through a combination of properties (downward), events (upward), and context (via attributes or the ElementInternals API).

The composition model follows HTML's native patterns:

  • Attributes for configuration (string values, observed via observedAttributes)
  • Properties for complex data (JavaScript objects, set via direct property access)
  • Events for communication (custom events dispatched with dispatchEvent)
  • Slots for content projection (allowing consumers to inject content)

Styling Strategies

Shadow DOM's style encapsulation is both a blessing and a challenge. Styles defined inside the shadow tree don't leak out, and external styles don't leak in. This provides true isolation but requires intentional design for customization.

Common styling approaches include:

CSS Custom Properties (Variables) pass through the shadow boundary, making them the preferred mechanism for theming. Define your component's customizable aspects using custom properties with sensible defaults:

:host {
  --button-bg: #007bff;
  --button-color: white;
  --button-radius: 4px;
}

CSS Parts (::part()) allow consumers to style specific elements within your shadow tree without exposing the entire internal structure:

/* Consumer can style this */
my-button::part(label) {
  font-weight: bold;
}

Constructable Stylesheets (Chrome 73+) enable sharing styles across multiple instances of the same component, reducing memory usage significantly in applications with many component instances.

Lifecycle Management

Custom Elements have a well-defined lifecycle that you hook into via callback methods:

  • connectedCallback() — Called when the element is added to the DOM. Ideal for setup, rendering, and event listener attachment.
  • disconnectedCallback() — Called when removed from the DOM. Use for cleanup: removing event listeners, clearing timers, disconnecting observers.
  • attributeChangedCallback(name, oldVal, newVal) — Called when an observed attribute changes. Use to synchronize properties with attributes.
  • adoptedCallback() — Called when the element is moved to a new document. Rarely used but important for iframe scenarios.

Proper lifecycle management is critical. Components that don't clean up in disconnectedCallback create memory leaks. Components that don't react to attribute changes become stale when their configuration is updated.

The ElementInternals API

The ElementInternals API (part of Custom Elements v1) allows custom elements to participate in native form submission, validation, and accessibility. This is essential for building form-associated components:

class MyInput extends HTMLElement {
  static get formAssociated() { return true; }
  
  constructor() {
    super();
    this.internals = this.attachInternals();
  }
  
  set value(v) {
    this.internals.setFormValue(v);
  }
}

This API bridges the gap between custom elements and the native form ecosystem, ensuring your components work seamlessly with <form> elements.

Step-by-Step Implementation

Let's build a practical, production-ready Web Component: a customizable alert/notification component that demonstrates all the core patterns.

Setting Up the Project

First, create a minimal project structure:

mkdir web-components-alert && cd web-components-alert
npm init -y
npm install lit @open-wc/testing@latest

For this tutorial, we'll build both a vanilla implementation and one using Lit (a lightweight Web Components library) to show both approaches.

Vanilla Web Component: MyAlert

Create src/my-alert.js:

const template = document.createElement('template');
template.innerHTML = `
  <style>
    :host {
      display: block;
      padding: 16px 20px;
      border-radius: 8px;
      margin: 8px 0;
      font-family: system-ui, sans-serif;
      position: relative;
      animation: slideIn 0.3s ease-out;
    }
    
    :host([type="info"]) {
      background: #e3f2fd;
      border-left: 4px solid #2196f3;
      color: #0d47a1;
    }
    
    :host([type="success"]) {
      background: #e8f5e9;
      border-left: 4px solid #4caf50;
      color: #1b5e20;
    }
    
    :host([type="warning"]) {
      background: #fff3e0;
      border-left: 4px solid #ff9800;
      color: #e65100;
    }
    
    :host([type="error"]) {
      background: #ffebee;
      border-left: 4px solid #f44336;
      color: #b71c1c;
    }
    
    .close-btn {
      position: absolute;
      right: 12px;
      top: 50%;
      transform: translateY(-50%);
      background: none;
      border: none;
      cursor: pointer;
      font-size: 18px;
      opacity: 0.7;
      padding: 4px;
    }
    
    .close-btn:hover { opacity: 1; }
    
    .title {
      font-weight: 600;
      margin-bottom: 4px;
    }
    
    .message {
      font-size: 14px;
      line-height: 1.5;
    }
    
    @keyframes slideIn {
      from { transform: translateX(100%); opacity: 0; }
      to { transform: translateX(0); opacity: 1; }
    }
    
    :host([dismissed]) {
      animation: slideOut 0.3s ease-in forwards;
    }
    
    @keyframes slideOut {
      to { transform: translateX(100%); opacity: 0; }
    }
  </style>
  
  <div class="title" part="title">
    <slot name="title"></slot>
  </div>
  <div class="message" part="message">
    <slot></slot>
  </div>
  <button class="close-btn" part="close-btn" aria-label="Dismiss">&times;</button>
`;
 
class MyAlert extends HTMLElement {
  static get observedAttributes() {
    return ['type', 'dismissible', 'duration'];
  }
 
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.appendChild(template.content.cloneNode(true));
    this._timeout = null;
  }
 
  connectedCallback() {
    const closeBtn = this.shadowRoot.querySelector('.close-btn');
    closeBtn.addEventListener('click', () => this.dismiss());
    
    if (!this.hasAttribute('role')) {
      this.setAttribute('role', 'alert');
    }
    
    this._setupAutoDismiss();
  }
 
  disconnectedCallback() {
    if (this._timeout) {
      clearTimeout(this._timeout);
      this._timeout = null;
    }
  }
 
  attributeChangedCallback(name, oldVal, newVal) {
    if (name === 'duration') {
      this._setupAutoDismiss();
    }
    if (name === 'dismissible') {
      const btn = this.shadowRoot.querySelector('.close-btn');
      if (btn) {
        btn.style.display = newVal === 'false' ? 'none' : '';
      }
    }
  }
 
  _setupAutoDismiss() {
    if (this._timeout) clearTimeout(this._timeout);
    const duration = parseInt(this.getAttribute('duration'));
    if (duration > 0) {
      this._timeout = setTimeout(() => this.dismiss(), duration);
    }
  }
 
  dismiss() {
    this.dispatchEvent(new CustomEvent('alert-dismiss', {
      bubbles: true,
      composed: true,
      detail: { type: this.getAttribute('type') }
    }));
    
    this.setAttribute('dismissed', '');
    this.addEventListener('animationend', () => {
      this.remove();
    }, { once: true });
  }
}
 
customElements.define('my-alert', MyAlert);

Advanced Feature: Form-Associated Input

Now let's build a form-associated custom input:

class MyInput extends HTMLElement {
  static get formAssociated() { return true; }
  static get observedAttributes() { 
    return ['type', 'placeholder', 'required', 'disabled', 'value']; 
  }
 
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.internals = this.attachInternals();
    this._value = '';
    
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: inline-block;
          font-family: system-ui, sans-serif;
        }
        .wrapper {
          position: relative;
          border: 2px solid #ddd;
          border-radius: 6px;
          padding: 10px 14px;
          transition: border-color 0.2s;
          background: white;
        }
        .wrapper:focus-within {
          border-color: #007bff;
          box-shadow: 0 0 0 3px rgba(0,123,255,0.15);
        }
        :host([invalid]) .wrapper {
          border-color: #dc3545;
        }
        input {
          border: none;
          outline: none;
          width: 100%;
          font-size: 16px;
          background: transparent;
        }
        :host([disabled]) .wrapper {
          background: #f5f5f5;
          opacity: 0.7;
        }
        .error-msg {
          color: #dc3545;
          font-size: 12px;
          margin-top: 4px;
          display: none;
        }
        :host([invalid]) .error-msg {
          display: block;
        }
      </style>
      <div class="wrapper">
        <input part="input" />
      </div>
      <div class="error-msg" part="error"></div>
    `;
  }
 
  connectedCallback() {
    const input = this.shadowRoot.querySelector('input');
    input.addEventListener('input', (e) => {
      this.value = e.target.value;
    });
    input.addEventListener('blur', () => this._validate());
    
    this._upgradeProperty('value');
    this._upgradeProperty('disabled');
    this._upgradeProperty('required');
  }
 
  _upgradeProperty(prop) {
    if (this.hasOwnProperty(prop)) {
      const value = this[prop];
      delete this[prop];
      this[prop] = value;
    }
  }
 
  get value() { return this._value; }
  set value(v) {
    this._value = v;
    this.internals.setFormValue(v);
    this._validate();
  }
 
  get form() { return this.internals.form; }
  get name() { return this.getAttribute('name'); }
  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(); }
 
  _validate() {
    const input = this.shadowRoot.querySelector('input');
    const errorEl = this.shadowRoot.querySelector('.error-msg');
    
    if (this.hasAttribute('required') && !this._value) {
      this.internals.setValidity({ valueMissing: true }, 'This field is required', input);
      errorEl.textContent = 'This field is required';
      this.setAttribute('invalid', '');
    } else {
      this.internals.setValidity({});
      errorEl.textContent = '';
      this.removeAttribute('invalid');
    }
  }
 
  attributeChangedCallback(name, oldVal, newVal) {
    const input = this.shadowRoot.querySelector('input');
    if (!input) return;
    
    switch (name) {
      case 'placeholder':
        input.placeholder = newVal || '';
        break;
      case 'disabled':
        input.disabled = newVal !== null;
        break;
      case 'type':
        input.type = newVal || 'text';
        break;
    }
  }
}
 
customElements.define('my-input', MyInput);

Implementation workflow diagram

Real-World Use Cases and Case Studies

Use Case 1: Design System Foundation

Companies like SAP (UI5 Web Components), ING Bank (Lion Web Components), and Adobe (Spectrum Web Components) have built their design systems entirely on Web Components. This approach allows them to maintain a single codebase that works across React, Angular, Vue, and vanilla JavaScript applications.

ING Bank's Lion library demonstrates this perfectly: they provide a set of unstyled, accessible Web Components that teams can style to match their brand while inheriting correct behavior and accessibility semantics. This eliminates the "rebuild the datepicker for every framework" problem that plagues many organizations.

Use Case 2: Micro-Frontend Architecture

When multiple teams work on different parts of a large application, Web Components provide natural boundaries. Each team can build their section as a Web Component (or set of components) and compose them at the application shell level. The shadow DOM ensures that team A's CSS doesn't break team B's layout.

IKEA's e-commerce platform uses this approach, allowing different teams to own their product pages, cart, and checkout flows independently. Each micro-frontend is a Web Component that can be deployed and updated independently.

Use Case 3: Embeddable Widgets

Third-party widgets—chat widgets, analytics dashboards, social media embeds—are perfect candidates for Web Components. They need to work on any website without conflicting with the host page's styles or JavaScript.

The GitHub button (<github-count>) and various Stripe embeds are examples of this pattern in production. The shadow DOM ensures the widget renders correctly regardless of the host page's CSS resets or global styles.

Use Case 4: Progressive Enhancement

Web Components can enhance existing HTML without requiring a full framework migration. An e-commerce site might add a <price-display> component that formats currency, shows discounts, and updates in real-time—gradually modernizing the UI without rewriting the entire application.

Best Practices for Production

  1. Use Shadow DOM strategically: Not every component needs shadow DOM. For simple presentational components where style inheritance is desirable (like <stack-layout>), skip it. Use shadow DOM when you need true encapsulation—third-party widgets, complex interactive components, or cross-framework libraries.

  2. Design for accessibility from the start: Use ElementInternals to expose ARIA roles and properties. Set appropriate role attributes in connectedCallback. Handle keyboard navigation within your component. Screen readers should understand your component's purpose without additional configuration.

  3. Prefer CSS Custom Properties for theming: Define all customizable visual aspects as CSS custom properties with sensible defaults. Document them clearly. This allows consumers to theme your components without touching internal styles or fighting specificity wars.

  4. Follow the extensible web manifesto: Build your components to expose low-level capabilities, not just high-level patterns. Provide escape hatches for advanced users. A date picker should allow custom date formatting, not just a format attribute with three options.

  5. Use slotchange events for dynamic content: When your component needs to react to projected content changes, listen for slotchange events rather than polling or using MutationObserver. This is the idiomatic way to handle dynamic slot content.

  6. Avoid constructor side effects: The constructor should only set up the shadow DOM and initial state. Don't access attributes, render to the DOM, or fetch data in the constructor—all of this belongs in connectedCallback. The element might be created but never added to the DOM.

  7. Implement proper cleanup in disconnectedCallback: Remove all event listeners attached to external targets (window, document), clear all timers, disconnect all observers. Components that don't clean up create memory leaks that are difficult to diagnose.

  8. Provide both attribute and property APIs: Attributes for declarative HTML usage, properties for programmatic JavaScript usage. Synchronize them via attributeChangedCallback and property setters. This dual API makes your component work in both static HTML and dynamic JavaScript contexts.

Common Pitfalls and Solutions

PitfallImpactSolution
Rendering in constructorDouble rendering, errors if element movesRender in connectedCallback, use a flag to prevent re-rendering
Not handling reconnectionComponent breaks when moved in DOMReset event listeners in connectedCallback, clean up in disconnectedCallback
Exposing shadow DOM internalsFragile API, breaking changesUse CSS ::part() for styling, slot for content
Forgetting composed: true on eventsEvents don't cross shadow boundariesAlways set composed: true for events consumers need to hear
Overusing !important in host stylesSpecificity wars, unstyleable componentsUse CSS custom properties instead of fighting specificity
Ignoring formAssociatedCustom form elements don't participate in formsImplement ElementInternals for any form-related component

Performance Optimization

Web Components have unique performance characteristics that require specific optimization strategies.

Minimize Shadow DOM Size

Each shadow root has overhead. For lists with thousands of items, consider using a single shadow root with virtual scrolling rather than creating a Web Component per item:

class VirtualList extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._items = [];
    this._visibleRange = { start: 0, end: 20 };
    
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          overflow-y: auto;
          contain: strict;
        }
        .viewport {
          position: relative;
          will-change: transform;
        }
        .item {
          height: var(--item-height, 40px);
          padding: 8px;
          box-sizing: border-box;
        }
      </style>
      <div class="viewport" part="viewport">
        <slot></slot>
      </div>
    `;
  }
 
  connectedCallback() {
    this.addEventListener('scroll', () => this._onScroll(), { passive: true });
  }
 
  _onScroll() {
    const scrollTop = this.scrollTop;
    const itemHeight = parseInt(getComputedStyle(this).getPropertyValue('--item-height')) || 40;
    const containerHeight = this.clientHeight;
    
    const start = Math.floor(scrollTop / itemHeight);
    const end = Math.ceil((scrollTop + containerHeight) / itemHeight);
    
    if (start !== this._visibleRange.start || end !== this._visibleRange.end) {
      this._visibleRange = { start, end };
      this._render();
    }
  }
 
  set items(data) {
    this._items = data;
    this._updateViewportHeight();
    this._render();
  }
 
  _updateViewportHeight() {
    const itemHeight = parseInt(getComputedStyle(this).getPropertyValue('--item-height')) || 40;
    this.shadowRoot.querySelector('.viewport').style.height = 
      `${this._items.length * itemHeight}px`;
  }
 
  _render() {
    // Only render visible items using requestAnimationFrame
    requestAnimationFrame(() => {
      // ... render logic for visible items only
    });
  }
}

Adopt Constructable Stylesheets

Instead of creating a new <style> element for each component instance, use constructable stylesheets to share styles:

const sharedStyles = new CSSStyleSheet();
sharedStyles.replaceSync(`
  :host {
    display: block;
    font-family: system-ui, sans-serif;
  }
  /* ... */
`);
 
class MyComponent extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.adoptedStyleSheets = [sharedStyles];
  }
}

This approach reduces memory usage by 30-50% when you have many instances of the same component, as the stylesheet is parsed once and shared.

Use contain CSS Property

Apply CSS containment to shadow DOM roots to limit the browser's rendering scope:

:host {
  contain: content; /* or 'strict' for fully independent components */
}

This tells the browser that changes inside this element don't affect layout outside it, enabling significant rendering optimizations.

Comparison with Alternatives

FeatureWeb ComponentsReact ComponentsVue ComponentsAngular Components
Framework dependencyNoneReact requiredVue requiredAngular required
EncapsulationShadow DOM (true)CSS Modules / Styled ComponentsScoped CSSViewEncapsulation
Browser supportAll modernAny (JS runtime)Any (JS runtime)Any (JS runtime)
Learning curveModerateModerateLow-ModerateSteep
Bundle size0KB (native)~40KB (React)~30KB (Vue)~150KB (Angular)
TypeScript supportManual typingExcellentExcellentExcellent
Server renderingDeclarative Shadow DOMFull SSRFull SSRUniversal
Community ecosystemGrowingMassiveLargeLarge
State managementExternal (any)Built-in + ReduxBuilt-in + VuexBuilt-in + NgRx
Form integrationElementInternalsControlled componentsv-modelReactive Forms

Advanced Patterns and Techniques

Mixins for Shared Behavior

Web Components don't support multiple inheritance, but mixins provide a clean alternative:

const FormMixin = (superclass) => class extends superclass {
  connectedCallback() {
    super.connectedCallback?.();
    this._form = this.closest('form');
    if (this._form) {
      this._form.addEventListener('submit', this._onFormSubmit.bind(this));
      this._form.addEventListener('reset', this._onFormReset.bind(this));
    }
  }
 
  disconnectedCallback() {
    super.disconnectedCallback?.();
    if (this._form) {
      this._form.removeEventListener('submit', this._onFormSubmit);
      this._form.removeEventListener('reset', this._onFormReset);
    }
  }
 
  _onFormSubmit(e) {
    // Override in component
  }
 
  _onFormReset(e) {
    // Override in component
  }
};
 
const FocusMixin = (superclass) => class extends superclass {
  connectedCallback() {
    super.connectedCallback?.();
    this._focusHandler = () => this.setAttribute('focused', '');
    this._blurHandler = () => this.removeAttribute('focused');
    this.addEventListener('focus', this._focusHandler);
    this.addEventListener('blur', this._blurHandler);
  }
 
  disconnectedCallback() {
    super.disconnectedCallback?.();
    this.removeEventListener('focus', this._focusHandler);
    this.removeEventListener('blur', this._blurHandler);
  }
};
 
// Usage
class MyInput extends FormMixin(FocusMixin(HTMLElement)) {
  // Combined behavior from both mixins
}

Reactive Properties with LitElement Patterns

While Lit provides this out of the box, understanding the pattern is valuable:

const propertyReactions = new Map();
 
function reactive(target, propertyKey) {
  if (!target.constructor.properties) {
    target.constructor.properties = {};
  }
  target.constructor.properties[propertyKey] = { type: String };
}
 
function createPropertyDescriptor(key, options) {
  return {
    get() { return this[`_${key}`]; },
    set(value) {
      const old = this[`_${key}`];
      this[`_${key}`] = value;
      this.requestUpdate(key, old);
    },
    configurable: true,
    enumerable: true
  };
}

Cross-Component Communication with Custom Events

class EventBus {
  constructor() {
    this._target = new EventTarget();
  }
 
  on(type, handler) {
    this._target.addEventListener(type, handler);
    return () => this._target.removeEventListener(type, handler);
  }
 
  emit(type, detail) {
    this._target.dispatchEvent(new CustomEvent(type, { detail }));
  }
}
 
// Shared event bus for loosely coupled components
window.__appEventBus = window.__appEventBus || new EventBus();

Testing Strategies

Testing Web Components requires specific approaches due to shadow DOM encapsulation.

Unit Testing with Web Test Runner

import { fixture, html, expect } from '@open-wc/testing';
import '../src/my-alert.js';
 
describe('my-alert', () => {
  it('renders with default type', async () => {
    const el = await fixture(html`
      <my-alert>Hello World</my-alert>
    `);
    
    expect(el.getAttribute('role')).to.equal('alert');
    expect(el.shadowRoot.querySelector('slot').assignedNodes()[0].textContent)
      .to.equal('Hello World');
  });
 
  it('applies correct styles for type attribute', async () => {
    const el = await fixture(html`
      <my-alert type="error">Error message</my-alert>
    `);
    
    const computed = getComputedStyle(el);
    expect(computed.borderLeftColor).to.equal('rgb(244, 67, 54)');
  });
 
  it('dispatches alert-dismiss event when dismissed', async () => {
    const el = await fixture(html`
      <my-alert>Dismissible</my-alert>
    `);
    
    let eventDetail = null;
    el.addEventListener('alert-dismiss', (e) => {
      eventDetail = e.detail;
    });
    
    el.shadowRoot.querySelector('.close-btn').click();
    expect(eventDetail).to.deep.equal({ type: null });
  });
 
  it('auto-dismisses after duration', async () => {
    const el = await fixture(html`
      <my-alert duration="100">Auto dismiss</my-alert>
    `);
    
    let dismissed = false;
    el.addEventListener('alert-dismiss', () => { dismissed = true; });
    
    // Wait for duration + animation
    await new Promise(r => setTimeout(r, 400));
    expect(dismissed).to.be.true;
  });
});

Integration Testing

import { fixture, html, expect } from '@open-wc/testing';
import '../src/my-input.js';
 
describe('my-input form integration', () => {
  it('participates in form submission', async () => {
    const form = await fixture(html`
      <form>
        <my-input name="username" required></my-input>
      </form>
    `);
    
    const input = form.querySelector('my-input');
    input.value = 'testuser';
    
    const formData = new FormData(form);
    expect(formData.get('username')).to.equal('testuser');
  });
 
  it('validates required field', async () => {
    const el = await fixture(html`
      <my-input required></my-input>
    `);
    
    el.value = '';
    expect(el.checkValidity()).to.be.false;
    expect(el.validity.valueMissing).to.be.true;
  });
});

Visual Regression Testing

Use tools like Percy or Chromatic to capture screenshots of your components in various states:

// Capture different states
const states = [
  { type: 'info', title: 'Information' },
  { type: 'success', title: 'Success' },
  { type: 'warning', title: 'Warning' },
  { type: 'error', title: 'Error' },
];
 
for (const state of states) {
  await fixture(html`
    <my-alert type="${state.type}">
      <span slot="title">${state.title}</span>
      Alert content for ${state.type} state
    </my-alert>
  `);
  // Capture screenshot for visual regression
}

Future Outlook

The Web Components ecosystem continues to evolve rapidly. Several exciting developments are shaping the future:

Declarative Shadow DOM (Chrome 111+) allows server-rendering of Web Components without JavaScript, solving the long-standing SEO and performance issues with client-side-only rendering. This is a game-changer for server-rendered applications that want to use Web Components.

Scoped Custom Element Registries address the naming collision problem by allowing custom elements to be registered within a specific scope rather than globally. This enables multiple versions of the same component to coexist—a requirement for micro-frontend architectures.

CSS @scope will provide more granular style encapsulation options, complementing Shadow DOM with a lighter-weight alternative for components that need partial style isolation.

The invoker commands proposal will allow custom elements to participate in the browser's native command/action system, making them more discoverable and easier to use declaratively.

The W3C Web Components Community Group is actively working on these specifications, with browser vendors showing strong commitment to the platform.

Conclusion

Web Components have matured from an ambitious specification into a production-ready technology used by some of the world's largest companies. Their framework-agnostic nature makes them uniquely valuable in an ecosystem characterized by rapid framework churn and organizational diversity.

Key takeaways from this guide:

  1. Web Components provide true encapsulation through Shadow DOM, preventing style and DOM leakage that plagues other component approaches.
  2. The three pillars—Custom Elements, Shadow DOM, and Templates—work together to create a complete component model that's native to the browser.
  3. Form integration via ElementInternals bridges the gap between custom and native form elements, ensuring accessibility and usability.
  4. Performance optimization requires specific strategies like constructable stylesheets, CSS containment, and shadow DOM size management.
  5. Testing shadow DOM components requires tools that understand encapsulation—use @open-wc/testing or similar libraries that provide shadow DOM-aware testing utilities.

For developers building design systems, embeddable widgets, or applications that need to outlive any single framework, Web Components are not just an option—they're the pragmatic choice. Start with simple presentational components, adopt the patterns outlined here, and gradually build up to complex, form-associated, fully accessible custom elements.

The web platform is the longest-lived runtime we have. Building on its native capabilities ensures your components will work today and continue working as frameworks rise and fall. Embrace the platform, and your components will stand the test of time.