The Browser Finally Became a Component Platform
For years, building reusable UI components required choosing a framework. React had JSX, Vue had single-file components, Angular had its module system. Every framework solved the same problem differently, and switching between them meant rewriting every component from scratch. Web Components change this equation fundamentally.
Web Components are a set of browser-native APIs that let you create custom HTML elements with encapsulated functionality. They work everywhere — in React, Vue, Svelte, plain HTML, or any other environment that renders to the DOM. The technology is no longer experimental. Every major browser supports it fully. Companies like GitHub, Adobe, Salesforce, and ING Bank run Web Components in production at scale.
This guide covers everything you need to build practical Web Components: custom elements, Shadow DOM for style encapsulation, HTML templates, and slots for flexible content composition. You will write two complete components, understand the lifecycle hooks, and learn when Web Components are the right choice versus framework-specific components in React, Vue, or Svelte.
What Are Web Components? The Three Core APIs
Web Components are not a single technology but a combination of three browser APIs that work together. Each API handles a specific concern, and you can use them independently or combine them for full encapsulation.
Custom Elements
The Custom Elements API lets you define new HTML tags with their own behavior. When you register a custom element, the browser treats it like any built-in element — you can use it in HTML, query it with JavaScript, and style it with CSS. Custom element names must contain a hyphen (like <user-card> or <data-table>) to avoid conflicts with current or future standard HTML elements.
Shadow DOM
Shadow DOM provides encapsulation for your component’s internal structure and styles. When you attach a shadow root to an element, the internal DOM tree becomes invisible to the outside document. Styles defined inside the shadow root do not leak out, and external styles do not leak in. This solves the CSS scoping problem that has plagued web development since the beginning — no more BEM naming conventions or CSS Modules just to prevent class name collisions.
HTML Templates and Slots
The <template> element holds HTML that is not rendered until you explicitly clone it with JavaScript. Combined with <slot> elements, templates create a content projection system where consumers of your component can insert their own content into predefined positions. This mirrors the children/props pattern from frameworks like React but uses native browser capabilities.
Building Your First Custom Element
Every custom element is a JavaScript class that extends HTMLElement. The class defines the element’s behavior, and the customElements.define() method registers it with a tag name. Here is the minimal structure:
class AlertBanner extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
const type = this.getAttribute('type') || 'info';
const dismissible = this.hasAttribute('dismissible');
const colors = {
info: { bg: '#e8f4fd', border: '#1976d2', icon: 'ℹ️' },
success: { bg: '#e8f5e9', border: '#388e3c', icon: '✓' },
warning: { bg: '#fff8e1', border: '#f9a825', icon: '⚠' },
error: { bg: '#fbe9e7', border: '#d32f2f', icon: '✗' }
};
const style = colors[type] || colors.info;
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
margin: 1rem 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.banner {
display: flex;
align-items: flex-start;
padding: 1rem 1.25rem;
border-left: 4px solid ${style.border};
background: ${style.bg};
border-radius: 0 6px 6px 0;
gap: 0.75rem;
line-height: 1.6;
}
.icon {
font-size: 1.2rem;
flex-shrink: 0;
margin-top: 0.1rem;
}
.content {
flex: 1;
color: #1a1a1a;
font-size: 0.95rem;
}
.close-btn {
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
color: #666;
padding: 0;
line-height: 1;
margin-left: auto;
}
.close-btn:hover {
color: #333;
}
::slotted(a) {
color: ${style.border};
text-decoration: underline;
}
</style>
<div class="banner">
<span class="icon">${style.icon}</span>
<div class="content">
<slot>Default alert message</slot>
</div>
${dismissible ? '<button class="close-btn" aria-label="Dismiss">×</button>' : ''}
</div>
`;
if (dismissible) {
this.shadowRoot.querySelector('.close-btn')
.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('dismiss', { bubbles: true }));
this.remove();
});
}
}
static get observedAttributes() {
return ['type'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== null && oldValue !== newValue) {
this.connectedCallback();
}
}
}
customElements.define('alert-banner', AlertBanner);
// Usage in HTML:
// <alert-banner type="warning" dismissible>
// Your session expires in 5 minutes.
// </alert-banner>
This component demonstrates several key patterns. The constructor() attaches a shadow root for encapsulation. The connectedCallback() fires when the element is inserted into the DOM, which is where you set up the internal structure. The observedAttributes static getter and attributeChangedCallback() enable the component to react when attributes change — similar to how props work in React or Vue.
Notice the :host selector in the shadow CSS. This targets the custom element itself from inside the shadow DOM, letting you control how the component appears in the page layout. The ::slotted() selector styles elements that consumers pass into the slot, though it only works for direct children of the slot.
Understanding Shadow DOM in Depth
Shadow DOM is the most powerful and most misunderstood part of Web Components. It creates a scoping boundary for both DOM and CSS, which has significant implications for how you build and integrate components.
Open vs Closed Shadow Roots
When attaching a shadow root, you choose between mode: 'open' and mode: 'closed'. With an open shadow root, external JavaScript can access the internal DOM through element.shadowRoot. With a closed shadow root, element.shadowRoot returns null, and external code cannot reach the internal structure.
In practice, use open mode almost always. Closed mode provides only superficial protection (developers can work around it), and it prevents legitimate use cases like testing, debugging, and third-party integrations. The browser’s own built-in elements (like <video> and <input type="range">) use closed shadow roots, but they have a different threat model than application-level components.
CSS Encapsulation Rules
Shadow DOM CSS encapsulation follows specific rules that differ from any framework-based approach:
- Internal styles stay internal. A
.buttonclass defined inside a shadow root will never affect elements outside that shadow root, even if they also have a.buttonclass. This eliminates the need for methodologies like BEM or CSS Modules when building components — the browser enforces scoping natively. - External styles stay external. Stylesheets loaded in the main document cannot target elements inside a shadow root. This protects your component’s appearance from being broken by global CSS, which is particularly valuable in large applications where CSS grid layouts and global styles could otherwise interfere with component internals.
- Inherited properties cross the boundary. CSS properties that inherit —
color,font-family,font-size,line-height, and others — do cross the shadow boundary. This means a shadow DOM component will naturally pick up the page’s typography unless you explicitly override it. - CSS custom properties cross the boundary. CSS variables (custom properties) also pierce the shadow boundary, which provides a clean theming mechanism. Define
--primary-coloron the document and use it inside your components.
Styling Shadow DOM from the Outside
While direct CSS targeting into shadow DOM is blocked, several mechanisms allow external control:
- CSS custom properties are the primary theming mechanism. Components define their styles using custom properties with fallbacks, and consumers override those properties from outside.
::part()pseudo-element exposes specific internal elements for external styling. A component marks elements with thepartattribute, and consumers use::part(button)to style them.:host-context()lets a component style itself based on its ancestor context. For example,:host-context(.dark-theme)applies styles when the component is placed inside a dark-themed container.
Slots: Content Composition Done Right
Slots enable content projection — the ability for a component consumer to inject their own content into predefined positions within the component. Named slots allow multiple insertion points, creating flexible and composable component APIs.
Consider a card component where the consumer provides the header, body, and footer content while the component controls the layout and styling. This pattern is foundational to building design systems with consistent UI patterns that remain flexible enough for diverse use cases.
Building a Data-Driven Component: Sortable Table
The second example builds a more complex, data-driven component: a sortable table that accepts JSON data and renders an interactive table with sort functionality. This pattern is common in dashboards, admin panels, and any data-heavy application.
class SortableTable extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._data = [];
this._columns = [];
this._sortCol = null;
this._sortDir = 'asc';
}
static get observedAttributes() {
return ['columns'];
}
set data(value) {
this._data = Array.isArray(value) ? value : [];
this._render();
}
get data() {
return this._data;
}
attributeChangedCallback(name, oldVal, newVal) {
if (name === 'columns' && newVal) {
try {
this._columns = JSON.parse(newVal);
this._render();
} catch (e) {
console.error('Invalid columns JSON:', e);
}
}
}
connectedCallback() {
const colsAttr = this.getAttribute('columns');
if (colsAttr) {
try { this._columns = JSON.parse(colsAttr); } catch (e) {}
}
// Check for inline JSON data in script tag
const script = this.querySelector('script[type="application/json"]');
if (script) {
try { this._data = JSON.parse(script.textContent); } catch (e) {}
}
this._render();
this.shadowRoot.addEventListener('click', (e) => {
const th = e.target.closest('th[data-key]');
if (!th) return;
const key = th.dataset.key;
if (this._sortCol === key) {
this._sortDir = this._sortDir === 'asc' ? 'desc' : 'asc';
} else {
this._sortCol = key;
this._sortDir = 'asc';
}
this._render();
});
}
_getSortedData() {
if (!this._sortCol) return [...this._data];
return [...this._data].sort((a, b) => {
const valA = a[this._sortCol];
const valB = b[this._sortCol];
const cmp = typeof valA === 'number'
? valA - valB
: String(valA).localeCompare(String(valB));
return this._sortDir === 'asc' ? cmp : -cmp;
});
}
_render() {
if (!this._columns.length) return;
const sorted = this._getSortedData();
const arrow = (key) => {
if (this._sortCol !== key) return ' ↕';
return this._sortDir === 'asc' ? ' ↑' : ' ↓';
};
this.shadowRoot.innerHTML = `
<style>
:host { display: block; overflow-x: auto; }
table {
width: 100%;
border-collapse: collapse;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 0.9rem;
}
th, td {
text-align: left;
padding: 0.75rem 1rem;
border-bottom: 1px solid #e5e5e5;
}
th {
background: #f5f5f5;
font-weight: 600;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
th:hover { background: #ebebeb; }
tr:hover td { background: #fafafa; }
.arrow { color: #888; font-size: 0.75rem; }
.empty {
text-align: center;
padding: 2rem;
color: #888;
}
</style>
<table role="grid" aria-label="Sortable data table">
<thead>
<tr>
${this._columns.map(col =>
`<th data-key="${col.key}" aria-sort="${
this._sortCol === col.key
? (this._sortDir === 'asc' ? 'ascending' : 'descending')
: 'none'
}">${col.label}<span class="arrow">${arrow(col.key)}</span></th>`
).join('')}
</tr>
</thead>
<tbody>
${sorted.length === 0
? `<tr><td colspan="${this._columns.length}" class="empty">No data available</td></tr>`
: sorted.map(row =>
`<tr>${this._columns.map(col =>
`<td>${row[col.key] ?? ''}</td>`
).join('')}</tr>`
).join('')}
</tbody>
</table>
`;
}
}
customElements.define('sortable-table', SortableTable);
// Usage:
// <sortable-table
// columns='[{"key":"name","label":"Name"},{"key":"role","label":"Role"},{"key":"exp","label":"Years"}]'>
// <script type="application/json">
// [{"name":"Alice","role":"Engineer","exp":5},
// {"name":"Bob","role":"Designer","exp":3}]
// </script>
// </sortable-table>
This component introduces several important patterns for real-world Web Components development:
- Property-based data passing. Complex data like arrays cannot be efficiently passed through HTML attributes. Instead, the component exposes a
dataproperty setter that accepts JavaScript arrays directly. The columns use attributes because they are typically static and small. - Inline JSON via script tags. For server-rendered pages, the component reads initial data from a
<script type="application/json">child element, enabling progressive enhancement without requiring JavaScript to set the data property. - ARIA attributes. The table includes
role="grid",aria-label, andaria-sortfor screen reader support. Building accessible components from the start is significantly easier than retrofitting accessibility later. - Event delegation. Instead of attaching a click listener to every header cell, the component uses event delegation on the shadow root, improving performance and simplifying the code.
Lifecycle Callbacks Explained
Custom elements have four lifecycle callbacks that fire at specific moments in the element’s existence. Understanding when each fires — and what you can safely do in each — is essential for building reliable components.
constructor()fires when the element is created (including by the HTML parser). At this point, the element has no attributes, no children, and is not in the document. Use it only for initial setup: attaching the shadow root, setting default state, and binding event handler references. Do not read attributes or access children here.connectedCallback()fires when the element is inserted into the document. This is where you read attributes, set up the initial render, start event listeners, and fetch data. Note that this callback can fire multiple times if the element is moved in the DOM.disconnectedCallback()fires when the element is removed from the document. Clean up event listeners, cancel timers, abort fetch requests, and release resources here. This is equivalent to React’suseEffectcleanup or Vue’sonUnmounted.attributeChangedCallback(name, oldValue, newValue)fires when an observed attribute changes. Only attributes listed in the staticobservedAttributesarray trigger this callback. Use it to react to external changes — updating the render when atypeorthemeattribute changes, for example.
When to Use Web Components (And When Not To)
Web Components are not a replacement for every framework in every context. They excel in specific scenarios and create unnecessary complexity in others.
Use Web Components When
- You are building a design system used across multiple frameworks. If your organization has teams using React, Vue, and Angular simultaneously, Web Components provide truly universal components. This is why major design systems from Adobe (Spectrum), SAP (UI5), and Salesforce (Lightning) chose Web Components.
- You are building embeddable widgets. Components embedded in third-party sites (chatbots, payment forms, analytics dashboards) benefit from Shadow DOM’s encapsulation. The host page’s CSS cannot break your widget, and your widget’s CSS cannot break the host page.
- You want zero runtime dependencies. Web Components require no framework, no build step, and no runtime library. A single JavaScript file defines the component. This makes them ideal for situations where bundle size matters or where you cannot control the build pipeline.
- You need future-proof components. Because Web Components use browser-native APIs, they will work as long as browsers exist. Framework components are tied to framework versions — a React 16 component may not work in React 19 without changes.
Avoid Web Components When
- Your entire application uses a single framework. If every developer on your team uses React and every page is a React application, framework components offer better developer experience, richer tooling, and tighter integration with the framework’s state management and routing.
- You need server-side rendering. Shadow DOM and SSR have a complicated relationship. Declarative Shadow DOM (supported in Chrome and Safari) improves the situation, but the ecosystem is not as mature as framework SSR solutions. For SSR-heavy sites like those built with JAMstack architecture, framework components may be more practical.
- Complex form handling is central. Web Components integrate with forms through the
ElementInternalsAPI, but the developer experience for complex form logic is still rougher than framework solutions with built-in form state management.
Integrating Web Components with Frameworks
One of the strongest arguments for Web Components is interoperability. A well-built Web Component works in any framework without wrappers or special configuration.
In React, custom elements work with a caveat: React 18 and earlier treated custom element properties as attributes, requiring workarounds for passing complex data. React 19 fully supports custom element properties, making integration seamless. In Vue, custom elements work naturally — Vue explicitly supports them and even allows you to compile Vue components as Web Components using defineCustomElement(). Svelte also treats custom elements as first-class citizens.
When choosing the best web framework for your project in 2026, consider how well it integrates with Web Components. If your organization might adopt different frameworks in different projects over time, a shared Web Component library provides stability that no single framework can offer. Teams managing complex projects with tools like taskee.pro can organize Web Component development alongside framework-specific work in the same workflow.
Performance Considerations
Web Components have unique performance characteristics that differ from framework components.
Shadow DOM has a cost. Each shadow root creates a separate DOM tree that the browser must manage. For components that render hundreds of instances (like cells in a data grid), the shadow root overhead matters. In these cases, consider using the component without Shadow DOM or using a single shadow root for the container with light DOM children.
Avoid innerHTML in render-heavy components. The examples in this article use innerHTML for clarity, but for components that re-render frequently, building the DOM with document.createElement() and updating only changed nodes is significantly faster. Libraries like Lit provide efficient template rendering on top of Web Components without the full weight of a framework.
For production applications where web performance optimization is critical, profile your components with Chrome DevTools. The Performance panel shows shadow root creation and style recalculation costs, helping you identify which components benefit from encapsulation and which should remain in the light DOM.
TypeScript and Web Components
Writing Web Components in TypeScript improves the development experience substantially. You get autocompletion for lifecycle methods, type checking for attribute values, and IDE support for your component’s public API. TypeScript also helps document the interface between your components and their consumers — especially important when multiple teams use the same component library.
When building professional-grade component libraries, a solid project structure matters. Organizations using toimi.pro for web development projects often establish component library architecture as a foundational project milestone, ensuring consistent patterns across all custom elements.
The Declarative Shadow DOM: Server-Side Rendering Solved
The biggest criticism of Web Components has been their dependence on JavaScript for rendering. Declarative Shadow DOM (DSD) addresses this by allowing shadow roots to be expressed in HTML without JavaScript:
With DSD, the browser attaches the shadow root during HTML parsing, before any JavaScript executes. This enables server-rendered Web Components that display content immediately and become interactive when the JavaScript loads — true progressive enhancement. Chrome and Safari support DSD natively, and Firefox added support in 2024, completing cross-browser availability.
This development is particularly significant for responsive web design where initial render speed directly impacts user experience across devices with varying processing power.
Building a Component Library: Practical Architecture
Moving from individual components to a component library requires architectural decisions about shared styles, documentation, testing, and distribution.
- Use CSS custom properties for theming. Define a set of design tokens as custom properties and use them consistently across all components. Consumers set the properties once, and every component adapts.
- Adopt a naming convention. Prefix all your elements with a consistent namespace:
<acme-button>,<acme-card>,<acme-modal>. This prevents collisions with other component libraries and makes your elements instantly recognizable in HTML. - Publish as ES modules. Distribute your components as individual ES modules that consumers can import selectively. This enables tree-shaking — unused components are never included in the final bundle.
- Document with Storybook. Storybook supports Web Components directly through its HTML framework. Create stories for each component state and variation, providing both documentation and visual testing.
- Test with Web Test Runner. The Modern Web project’s test runner (
@web/test-runner) runs tests in real browsers with excellent Web Components support. Combined with@open-wc/testing, it provides assertions for shadow DOM content, slot assignments, and custom events.
Looking Ahead: Web Components in 2025 and Beyond
The Web Components ecosystem continues to evolve. CSS custom state (:state()) lets components expose internal states for external styling — a button can expose its :state(loading) state, and consumers style it without knowing the internal implementation. Scoped custom element registries (currently in development) will allow different versions of the same component to coexist on a page, solving the version conflict problem in micro-frontend architectures.
The combination of Declarative Shadow DOM, ElementInternals for form participation, and CSS ::part() for controlled styling has addressed most of the historical objections to Web Components. What remains is ecosystem maturity — more libraries, more examples, more battle-tested patterns — and that is growing rapidly.
For developers starting with HTML5 fundamentals, Web Components represent a natural next step: they extend HTML itself rather than replacing it with a framework-specific abstraction. The concepts you learn — custom elements, shadow DOM, templates, slots — are browser standards that will remain relevant regardless of which frameworks rise or fall in popularity.
Frequently Asked Questions
Do Web Components work in all modern browsers?
Yes. Custom Elements v1, Shadow DOM v1, HTML templates, and slots are fully supported in Chrome, Firefox, Safari, and Edge. Declarative Shadow DOM, the most recent addition, reached full cross-browser support in 2024 when Firefox shipped its implementation. For legacy browsers like Internet Explorer 11, polyfills are available through the @webcomponents/webcomponentsjs package, though IE 11 usage has dropped below 0.5% globally and most projects no longer support it.
Can I use Web Components inside React, Vue, or Angular applications?
Yes, and this is one of their primary advantages. In Vue and Angular, Web Components work natively without any special configuration. React 19 added full support for custom element properties, resolving the main integration issue from earlier versions. For React 18 and earlier, you need to use ref callbacks to set properties on custom elements rather than passing them as JSX attributes. Libraries like @lit/react provide wrapper generators that create proper React components from Web Components automatically.
How do Web Components compare to React or Vue components in performance?
For individual components, the performance difference is negligible. Web Components have slightly lower overhead since they do not require a virtual DOM diffing step — updates go directly to the real DOM. However, when rendering large lists or frequently updating many components simultaneously, frameworks with optimized virtual DOM implementations can batch updates more efficiently. Shadow DOM adds a small per-instance cost for style encapsulation. In practice, the choice between Web Components and framework components should be driven by architectural needs rather than performance alone.
What is the best library for building Web Components?
Lit (formerly LitElement), maintained by Google, is the most popular and mature library for building Web Components. It adds efficient template rendering, reactive properties, and a declarative lifecycle on top of native Web Components in under 5KB. Other options include Stencil (from the Ionic team), which adds JSX-like syntax and a compiler that generates optimized vanilla Web Components, FAST Element from Microsoft, and Haunted, which brings React-style hooks to Web Components. All of these remain thin layers over browser-native APIs, unlike full application frameworks.
Should I use Shadow DOM for every Web Component?
No. Shadow DOM is valuable when you need style encapsulation — preventing external CSS from breaking your component or preventing your component’s styles from leaking into the page. For internal application components where you control the entire CSS environment, using Web Components without Shadow DOM (light DOM) is a valid choice that avoids the encapsulation overhead and simplifies styling. Components intended for distribution or use across different projects benefit more from Shadow DOM, since you cannot predict the CSS environment they will encounter.