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:
| Team | Stack | Component Library |
|---|---|---|
| Marketing | Next.js | React components |
| Dashboard | Angular | Angular components |
| Mobile | React Native | Shared logic only |
| Docs | Astro | Framework-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:
| Library | Output | Best For |
|---|---|---|
| Lit | Web Components | Lightweight, framework-agnostic |
| Stencil | Web Components | Complex components, TypeScript |
| Svelte | Both | Compile-time optimization |
| Fast Element | Web Components | Microsoft 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:
| Approach | Bundle 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:
- Explore modern frontend stack options for 2026
- Learn about local-first software architecture for offline-capable components
- Check out zero-JS frontend revolution approaches for maximum performance
What’s your experience with Web Components? Connect with me on Twitter to discuss!
You might also like
Local-First Software: Why the Future of Web Apps is Offline-Ready
Exploring the shift toward local-first architecture where the cloud is a synchronization layer, not the primary source of truth.
Prototype Showcase: Turn Any App Into a Live Portfolio Demo
How to clone real projects into standalone interactive prototypes with mock authentication, dummy data, and zero backend dependencies for portfolio demos.
API Gateway Patterns: The Front Door to Your Microservices
Master API Gateway patterns for microservices architecture. Learn request routing, authentication, rate limiting, and service mesh integration with TypeScript examples.
More Posts
API Gateway Patterns: The Front Door to Your Microservices
Building Autonomous AI Workflows with LangGraph: A Practical Guide
Building Type-Safe APIs with tRPC in 2026: Full-Stack TypeScript Without Schemas
Database Connection Pooling: Patterns for High-Performance Applications
Prompt Caching: Reduce LLM Costs by 90% with Smart Context Management
Building Resilient APIs: Circuit Breakers, Retries, and Rate Limiting in Production
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.
