Skip to content
· 11 min read · 0 views

Web Components 2026: Building Framework-Agnostic UI Libraries

Learn how to build reusable Web Components that work across React, Vue, Angular, and vanilla JS. Create framework-agnostic UI libraries with Shadow DOM and Custom Elements.

// table of contents (32 sections)

Web Components 2026: Building Framework-Agnostic UI Libraries

The frontend landscape has fragmented into countless frameworks—React, Vue, Angular, Svelte, Solid, and more. Each has its own component model, making it challenging to share UI components across projects. Web Components offer a powerful solution: native browser APIs that let you build components working everywhere.

In this guide, I’ll show you how to create production-ready Web Components using Custom Elements, Shadow DOM, and modern best practices. For understanding how this fits into broader frontend architecture decisions, see my post on choosing between SSG and SSR approaches.

Why Web Components in 2026?

The Framework Fragmentation Problem

Modern development teams often juggle multiple frameworks:

TeamStackComponent Library
MarketingNext.jsReact components
DashboardAngularAngular components
MobileReact NativeShared logic only
DocsAstroFramework-agnostic

Sharing UI across these stacks requires duplicating effort or maintaining multiple versions. Web Components solve this by compiling to native browser APIs.

Browser Support is Mature

In 2026, Web Components have near-universal support:

  • Custom Elements v1: 98.5% global support
  • Shadow DOM v1: 97.8% global support
  • HTML Templates: 98.2% global support
  • CSS Shadow Parts: 96.1% global support

No polyfills needed for modern browsers. This is production-ready technology.

When to Use Web Components

Great for:

  • Design systems used across multiple frameworks
  • Component libraries for third-party integration
  • Embeddable widgets (chat, forms, players)
  • Legacy system modernization

Not ideal for:

  • Single-framework applications (use native components)
  • Heavy data management (frameworks handle this better)
  • Complex state coordination (use framework state)

Core Technologies Explained

Custom Elements

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

class MyButton extends HTMLElement {
  connectedCallback() {
    this.innerHTML = `<button class="my-btn">Click Me</button>`;
    this.querySelector('button').addEventListener('click', () => {
      this.dispatchEvent(new CustomEvent('my-click', { bubbles: true }));
    });
  }
}

customElements.define('my-button', MyButton);

Use it in HTML:

<my-button></my-button>

Shadow DOM

Shadow DOM encapsulates styles and markup, preventing CSS leakage:

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

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          border: 1px solid #ccc;
          border-radius: 8px;
          padding: 1rem;
          background: white;
        }
        .title {
          font-size: 1.25rem;
          font-weight: bold;
          margin-bottom: 0.5rem;
        }
      </style>
      <div class="title"><slot name="title"></slot></div>
      <div class="content"><slot></slot></div>
    `;
  }
}

customElements.define('ui-card', CardComponent);

Usage:

<ui-card>
  <h2 slot="title">Card Title</h2>
  <p>This is the card content.</p>
</ui-card>

HTML Templates

Templates define inert markup that won’t render until activated:

<template id="user-template">
  <style>
    .user-card { display: flex; gap: 1rem; }
    .avatar { width: 48px; height: 48px; border-radius: 50%; }
  </style>
  <div class="user-card">
    <img class="avatar" src="" alt="Avatar">
    <div class="info">
      <span class="name"></span>
      <span class="email"></span>
    </div>
  </div>
</template>

Building a Production-Ready Component

Let’s build a complete ui-rating component with accessibility, theming, and events:

class UiRating extends HTMLElement {
  static get observedAttributes() {
    return ['value', 'max', 'readonly', 'size'];
  }

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._value = 0;
    this._max = 5;
    this._readonly = false;
    this._size = 'medium';
  }

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

  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue === newValue) return;
    
    switch (name) {
      case 'value':
        this._value = parseInt(newValue) || 0;
        break;
      case 'max':
        this._max = parseInt(newValue) || 5;
        break;
      case 'readonly':
        this._readonly = newValue !== null;
        break;
      case 'size':
        this._size = newValue || 'medium';
        break;
    }
    this._render();
  }

  _render() {
    const sizeMap = {
      small: '1rem',
      medium: '1.5rem',
      large: '2rem'
    };
    const fontSize = sizeMap[this._size] || '1.5rem';
    
    let stars = '';
    for (let i = 1; i <= this._max; i++) {
      const filled = i <= this._value;
      const icon = filled ? '★' : '☆';
      stars += `
        <button
          type="button"
          class="star ${filled ? 'filled' : ''}"
          data-value="${i}"
          aria-label="Rate ${i} out of ${this._max}"
          ${this._readonly ? 'disabled' : ''}
        >${icon}</button>
      `;
    }

    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: inline-flex;
          gap: 0.125rem;
        }
        .star {
          background: none;
          border: none;
          cursor: pointer;
          font-size: ${fontSize};
          color: var(--rating-color, #fbbf24);
          transition: transform 0.15s ease, color 0.15s ease;
          padding: 0;
          line-height: 1;
        }
        .star:hover:not(:disabled) {
          transform: scale(1.2);
        }
        .star.filled {
          color: var(--rating-color, #fbbf24);
        }
        .star:not(.filled) {
          color: var(--rating-empty, #d1d5db);
        }
        .star:disabled {
          cursor: default;
        }
        :host(:focus-within) .star:focus {
          outline: 2px solid var(--rating-focus, #3b82f6);
          outline-offset: 2px;
          border-radius: 4px;
        }
      </style>
      <div class="rating" role="radiogroup" aria-label="Rating">
        ${stars}
      </div>
    `;
    
    if (!this._readonly) {
      this._attachEvents();
    }
  }

  _attachEvents() {
    this.shadowRoot.querySelectorAll('.star').forEach(star => {
      star.addEventListener('click', (e) => {
        const value = parseInt(e.target.dataset.value);
        this._value = value;
        this.setAttribute('value', value);
        this.dispatchEvent(new CustomEvent('change', {
          detail: { value },
          bubbles: true,
          composed: true
        }));
      });
    });
  }

  get value() {
    return this._value;
  }

  set value(val) {
    this._value = parseInt(val) || 0;
    this.setAttribute('value', this._value);
  }
}

customElements.define('ui-rating', UiRating);

Usage:

<ui-rating value="3" size="large"></ui-rating>
<ui-rating value="4" readonly></ui-rating>
<ui-rating id="interactive"></ui-rating>

<script>
  document.getElementById('interactive').addEventListener('change', (e) => {
    console.log('Rated:', e.detail.value);
  });
</script>

Framework Integration

React Integration

React requires wrappers for Web Components. In 2026, React 19+ handles this seamlessly:

// React 19+ with automatic event handling
function ProductCard({ product }) {
  return (
    <div className="product">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <ui-rating 
        value={product.rating} 
        readonly 
        size="small" 
      />
      <span>${product.price}</span>
    </div>
  );
}

For older React versions, use a wrapper:

import React, { useRef, useEffect } from 'react';

function RatingInput({ value, onChange }) {
  const ref = useRef();

  useEffect(() => {
    const element = ref.current;
    const handleChange = (e) => onChange(e.detail.value);
    element.addEventListener('change', handleChange);
    return () => element.removeEventListener('change', handleChange);
  }, [onChange]);

  return <ui-rating ref={ref} value={value}></ui-rating>;
}

Vue Integration

Vue 3 works with Web Components out of the box:

<template>
  <div class="review">
    <ui-rating 
      :value="rating" 
      size="large"
      @change="handleRatingChange"
    />
    <textarea v-model="comment" placeholder="Your review..." />
  </div>
</template>

<script setup>
import { ref } from 'vue';

const rating = ref(0);
const comment = ref('');

function handleRatingChange(e) {
  rating.value = e.detail.value;
}
</script>

Configure Vue to recognize custom elements:

// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [
    vue({
      template: {
        compilerOptions: {
          isCustomElement: (tag) => tag.startsWith('ui-')
        }
      }
    })
  ]
});

Angular Integration

Angular requires CUSTOM_ELEMENTS_SCHEMA:

// app.module.ts
import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';

@NgModule({
  schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class AppModule {}
// rating.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-rating',
  template: `
    <ui-rating 
      [value]="rating"
      (change)="onRatingChange($event)"
    ></ui-rating>
  `
})
export class RatingComponent {
  rating = 0;

  onRatingChange(event: CustomEvent) {
    this.rating = event.detail.value;
  }
}

Styling and Theming

CSS Custom Properties

Shadow DOM blocks external styles, but CSS custom properties pass through:

// Component definition
this.shadowRoot.innerHTML = `
  <style>
    :host {
      --button-bg: var(--ui-button-bg, #3b82f6);
      --button-text: var(--ui-button-text, white);
      --button-radius: var(--ui-button-radius, 8px);
      
      display: inline-block;
    }
    button {
      background: var(--button-bg);
      color: var(--button-text);
      border-radius: var(--button-radius);
      padding: 0.5rem 1rem;
      border: none;
      cursor: pointer;
    }
  </style>
  <button><slot></slot></button>
`;

Application styling:

:root {
  --ui-button-bg: #10b981;
  --ui-button-text: white;
  --ui-button-radius: 9999px;
}

.dark-theme {
  --ui-button-bg: #6366f1;
  --ui-button-text: #f5f5f5;
}

CSS Shadow Parts

For granular styling control, use the part attribute:

this.shadowRoot.innerHTML = `
  <style>
    .container { padding: 1rem; }
    .header { font-weight: bold; }
    .body { color: #666; }
  </style>
  <div class="container" part="container">
    <div class="header" part="header"><slot name="header"></slot></div>
    <div class="body" part="body"><slot></slot></div>
  </div>
`;

External styling:

ui-card::part(header) {
  font-size: 1.25rem;
  color: navy;
}

ui-card::part(container) {
  border: 2px solid currentColor;
}

Best Practices

1. Use the ui- Prefix

Avoid conflicts with future HTML elements:

// Good: Namespaced
customElements.define('ui-datepicker', DatePicker);
customElements.define('ui-modal', Modal);

// Bad: Generic names that might conflict
customElements.define('datepicker', DatePicker);
customElements.define('modal', Modal);

2. Lifecycle Methods

Understand the component lifecycle:

class MyComponent extends HTMLElement {
  constructor() {
    super(); // Always call super() first
    // Initialize state, attach shadow DOM
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    // Element added to DOM
    // Good for: initial render, event listeners
  }

  disconnectedCallback() {
    // Element removed from DOM
    // Good for: cleanup, removing listeners
  }

  attributeChangedCallback(name, oldValue, newValue) {
    // Attribute changed
    // Good for: reactive updates
  }

  adoptedCallback() {
    // Element moved to new document
    // Rare: mainly for iframes
  }
}

3. Form Integration

Make components work with forms:

class UiInput extends HTMLElement {
  static get formAssociated() { return true; }

  constructor() {
    super();
    this.attachInternals(); // Get form integration APIs
  }

  get value() {
    return this._value;
  }

  set value(val) {
    this._value = val;
    this.internals.setFormValue(val); // Sync with form
  }

  checkValidity() {
    return this.internals.checkValidity();
  }
}

4. Accessibility

Implement ARIA patterns:

class UiTabs extends HTMLElement {
  connectedCallback() {
    this.setAttribute('role', 'tablist');
    this._render();
    this._setupKeyboardNav();
  }

  _setupKeyboardNav() {
    this.addEventListener('keydown', (e) => {
      const tabs = this.querySelectorAll('[role="tab"]');
      const currentIndex = Array.from(tabs).findIndex(t => t === e.target);

      switch (e.key) {
        case 'ArrowRight':
          tabs[(currentIndex + 1) % tabs.length].focus();
          break;
        case 'ArrowLeft':
          tabs[(currentIndex - 1 + tabs.length) % tabs.length].focus();
          break;
        case 'Home':
          tabs[0].focus();
          break;
        case 'End':
          tabs[tabs.length - 1].focus();
          break;
      }
    });
  }
}

For more on building accessible web applications, see my guide on optimizing Core Web Vitals for SEO.

Build Tools and Libraries

Lit

Google’s Lit library simplifies Web Components:

import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';

@customElement('ui-counter')
export class UiCounter extends LitElement {
  static styles = css`
    :host {
      display: block;
    }
    button {
      padding: 0.5rem 1rem;
      font-size: 1rem;
    }
  `;

  @property({ type: Number })
  count = 0;

  render() {
    return html`
      <button @click=${() => this.count++}>
        Count: ${this.count}
      </button>
    `;
  }
}

Stencil

Stencil compiles TypeScript to optimized Web Components:

import { Component, Prop, h, Event, EventEmitter } from '@stencil/core';

@Component({
  tag: 'ui-toggle',
  styleUrl: 'toggle.css',
  shadow: true,
})
export class UiToggle {
  @Prop() checked = false;
  @Prop() label = '';
  @Event() toggleChange: EventEmitter<boolean>;

  render() {
    return (
      <label>
        <span>{this.label}</span>
        <input
          type="checkbox"
          checked={this.checked}
          onChange={(e) => this.toggleChange.emit((e.target as HTMLInputElement).checked)}
        />
      </label>
    );
  }
}

Hybrid Libraries

Some libraries output both framework components and Web Components:

LibraryOutputBest For
LitWeb ComponentsLightweight, framework-agnostic
StencilWeb ComponentsComplex components, TypeScript
SvelteBothCompile-time optimization
Fast ElementWeb ComponentsMicrosoft ecosystem

Real-World Example: Design System

Here’s how to structure a complete design system:

ui-design-system/
├── components/
│   ├── button/
│   │   ├── UiButton.ts
│   │   ├── button.styles.ts
│   │   └── button.test.ts
│   ├── input/
│   │   ├── UiInput.ts
│   │   └── input.styles.ts
│   ├── card/
│   │   ├── UiCard.ts
│   │   └── card.styles.ts
│   └── index.ts
├── tokens/
│   └── tokens.css
├── themes/
│   ├── light.css
│   └── dark.css
└── index.ts

Build command:

// vite.config.js
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    lib: {
      entry: 'src/index.ts',
      name: 'UIDesignSystem',
      fileName: (format) => `ui-design-system.${format}.js`
    }
  }
});

Usage across frameworks:

// In any project
import 'ui-design-system';
import 'ui-design-system/themes/dark.css';

// Now all <ui-*> components are available

Testing Web Components

Use the Web Test Runner for testing:

// button.test.ts
import { expect, fixture, html } from '@open-wc/testing';
import './UiButton.js';

describe('UiButton', () => {
  it('renders with label', async () => {
    const el = await fixture(html`<ui-button>Click Me</ui-button>`);
    expect(el.textContent).to.include('Click Me');
  });

  it('dispatches click event', async () => {
    const el = await fixture(html`<ui-button>Click</ui-button>`);
    let clicked = false;
    el.addEventListener('click', () => clicked = true);
    el.shadowRoot.querySelector('button').click();
    expect(clicked).to.be.true;
  });

  it('respects disabled attribute', async () => {
    const el = await fixture(html`<ui-button disabled>Click</ui-button>`);
    expect(el.shadowRoot.querySelector('button').disabled).to.be.true;
  });
});

Performance Considerations

Bundle Size

Web Components are inherently small:

ApproachBundle Size
React + Component~45KB minified
Vue + Component~35KB minified
Vanilla Web Component~2-5KB

Registration Timing

Avoid layout shift by defining components early:

<head>
  <script type="module">
    // Define critical components inline
    customElements.define('ui-header', class extends HTMLElement {
      connectedCallback() {
        this.innerHTML = `<header>...</header>`;
      }
    });
  </script>
</head>

Lazy Loading

Load non-critical components on demand:

// Lazy load modal only when needed
document.querySelector('button').addEventListener('click', async () => {
  await import('./components/UiModal.js');
  document.body.appendChild(document.createElement('ui-modal'));
});

Conclusion

Web Components in 2026 provide a mature, standards-based approach to building framework-agnostic UI libraries. With native browser support, small bundle sizes, and seamless framework integration, they’re ideal for design systems, component libraries, and embeddable widgets.

Key Takeaways:

  • Use Custom Elements, Shadow DOM, and CSS custom properties for encapsulation
  • Integrate with frameworks using their native Web Component support
  • Follow naming conventions (ui- prefix) and accessibility patterns
  • Choose build tools (Lit, Stencil) based on project complexity
  • Test with Web Test Runner and monitor bundle sizes

Next Steps:


What’s your experience with Web Components? Connect with me on Twitter to discuss!

You might also like

Enjoyed This Post?

Want to discuss the topic, have questions, or looking to collaborate on something similar? Drop a comment below or reach out directly.

Discussion