Introduction
Web Components represent the web platform’s native answer to component-based architecture. Rather than relying on framework-specific abstractions, Web Components leverage browser standards that work across any technology stack. This means the components you build today will work in React, Vue, Angular, or plain HTML—forever.
The technology has matured significantly since its inception. What began as a collection of experimental APIs has evolved into a stable foundation for building reusable UI elements. Browser support is now universal, and the ecosystem includes libraries, tooling, and patterns that make Web Components practical for production applications.
In 2026, Web Components have found their place in the web development ecosystem. They’re the common denominator—the glue that connects different frameworks and enables true code sharing across organizational boundaries. This comprehensive guide covers everything from fundamentals to advanced patterns, helping you build robust, reusable components.
Core Technologies
Web Components rest on three pillars: Custom Elements, Shadow DOM, and HTML Templates. Understanding these technologies is essential for effective component development.
Custom Elements
Custom Elements let developers define new HTML tags with custom behavior:
class MyElement extends HTMLElement {
constructor() {
super();
// Initialize component
}
connectedCallback() {
// Element added to DOM
}
disconnectedCallback() {
// Element removed from DOM
}
attributeChangedCallback(name, oldValue, newValue) {
// Attribute changed
}
static get observedAttributes() {
return ['value', 'disabled'];
}
}
// Register the custom element
customElements.define('my-element', MyElement);
Once registered, use the element like any HTML tag:
<my-element value="Hello"></my-element>
The browser handles instantiation, lifecycle management, and garbage collection automatically. Your class defines the behavior.
Shadow DOM
Shadow DOM provides encapsulation, isolating component styles and markup from the rest of the page:
class MyComponent extends HTMLElement {
constructor() {
super();
// Attach shadow DOM
const shadow = this.attachShadow({ mode: 'open' });
// Add internal content
shadow.innerHTML = `
<style>
:host {
display: block;
padding: 16px;
background: #f5f5f5;
}
:host([hidden]) {
display: none;
}
.content {
color: #333;
}
</style>
<div class="content">
<slot></slot>
</div>
`;
}
}
Styles defined inside the shadow tree don’t leak out, and page styles don’t penetrate in. This isolation is crucial for building reliable, reusable components.
HTML Templates
The <template> element defines reusable markup that isn’t rendered until activated:
<template id="card-template">
<style>
.card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
}
</style>
<div class="card">
<h2><slot name="title">Default Title</slot></h2>
<p><slot>Default content</slot></p>
</div>
</template>
class CardElement extends HTMLElement {
constructor() {
super();
const template = document.getElementById('card-template');
const shadow = this.attachShadow({ mode: 'open' });
shadow.appendChild(template.content.cloneNode(true));
}
}
Templates are parsed once and reused across instances, making them memory-efficient.
Building Your First Component
Let’s build a practical component to understand the complete development workflow.
A Custom Button Component
Create a button component with built-in styling and behavior:
class ActionButton extends HTMLElement {
static get observedAttributes() {
return ['variant', 'disabled'];
}
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host {
display: inline-block;
}
:host([disabled]) {
pointer-events: none;
opacity: 0.6;
}
button {
font-family: inherit;
font-size: 14px;
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
}
button:active {
transform: scale(0.98);
}
:host([variant="primary"]) button {
background: #0066cc;
color: white;
}
:host([variant="primary"]) button:hover {
background: #0055aa;
}
:host([variant="danger"]) button {
background: #dc3545;
color: white;
}
:host([variant="danger"]) button:hover {
background: #c82333;
}
:host(:not([variant])) button {
background: #6c757d;
color: white;
}
:host(:not([variant])) button:hover {
background: #5a6268;
}
</style>
<button type="button">
<slot></slot>
</button>
`;
this.button = shadow.querySelector('button');
}
connectedCallback() {
this.button.addEventListener('click', this._handleClick);
}
disconnectedCallback() {
this.button.removeEventListener('click', this._handleClick);
}
_handleClick = (event) => {
if (this.disabled) return;
this.dispatchEvent(new CustomEvent('action', {
bubbles: true,
composed: true,
detail: { originalEvent: event }
}));
}
get disabled() {
return this.hasAttribute('disabled');
}
set disabled(value) {
if (value) {
this.setAttribute('disabled', '');
} else {
this.removeAttribute('disabled');
}
}
get variant() {
return this.getAttribute('variant') || 'default';
}
set variant(value) {
this.setAttribute('variant', value);
}
}
customElements.define('action-button', ActionButton);
Use the component:
<action-button variant="primary" id="submit-btn">
Submit Form
</action-button>
<script>
document.getElementById('submit-btn')
.addEventListener('action', (e) => console.log('Clicked!'));
</script>
This component encapsulates styling, handles events, exposes properties, and communicates through standard DOM mechanisms.
Component Lifecycle
Understanding lifecycle callbacks helps manage resources and maintain correctness.
Lifecycle Overview
The custom elements specification defines several lifecycle callbacks:
class LifecycleComponent extends HTMLElement {
constructor() {
super();
console.log('1. Constructor - element created');
}
connectedCallback() {
console.log('2. Connected - added to DOM');
}
disconnectedCallback() {
console.log('3. Disconnected - removed from DOM');
}
attributeChangedCallback(name, oldValue, newValue) {
console.log(`4. Attribute ${name} changed from ${oldValue} to ${newValue}`);
}
adoptedCallback() {
console.log('5. Adopted - moved to new document');
}
}
Use these callbacks to manage subscriptions, set up observers, and clean up resources.
Timing Considerations
Lifecycle callbacks fire at specific times:
class AsyncComponent extends HTMLElement {
async connectedCallback() {
// DOM is available but might not be fully rendered
await this.updateComplete;
// Element is fully rendered
this._setup();
}
get updateComplete() {
return this._updating ? Promise.resolve() : Promise.resolve(true);
}
}
The updateComplete promise resolves after all synchronous operations finish, useful for testing and coordination.
Cleanup
Always clean up in disconnectedCallback:
class CleanupComponent extends HTMLElement {
connectedCallback() {
this._observer = new MutationObserver(this._handleMutations);
this._observer.observe(this, { attributes: true });
this._clickHandler = (e) => this._handleClick(e);
document.addEventListener('click', this._clickHandler);
this._interval = setInterval(this._tick, 1000);
}
disconnectedCallback() {
// Clean up everything
this._observer.disconnect();
document.removeEventListener('click', this._clickHandler);
clearInterval(this._interval);
}
}
Failing to clean up causes memory leaks and unexpected behavior when components are removed and re-added.
Properties and Attributes
Managing the relationship between JavaScript properties and HTML attributes is crucial for useful components.
Reflecting Properties to Attributes
Changes to JavaScript properties should update attributes for consistency:
class ToggleSwitch extends HTMLElement {
static get observedAttributes() { return ['checked', 'disabled']; }
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._render();
}
_render() {
this.shadowRoot.innerHTML = `
<style>
:host { display: inline-block; }
.switch {
width: 40px;
height: 22px;
background: #ccc;
border-radius: 11px;
position: relative;
cursor: pointer;
}
.switch[checked] {
background: #4caf50;
}
.knob {
width: 18px;
height: 18px;
background: white;
border-radius: 50%;
position: absolute;
top: 2px;
left: 2px;
transition: transform 0.2s;
}
.switch[checked] .knob {
transform: translateX(18px);
}
</style>
<div class="switch" role="switch" aria-checked="false">
<div class="knob"></div>
</div>
`;
this._switch = this.shadowRoot.querySelector('.switch');
this._switch.addEventListener('click', () => this.toggle());
}
get checked() { return this.hasAttribute('checked'); }
set checked(value) {
const isChecked = Boolean(value);
if (isChecked) {
this.setAttribute('checked', '');
} else {
this.removeAttribute('checked');
}
}
toggle() {
this.checked = !this.checked;
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'checked') {
if (newValue !== null) {
this._switch.setAttribute('checked', '');
this._switch.setAttribute('aria-checked', 'true');
} else {
this._switch.removeAttribute('checked');
this._switch.setAttribute('aria-checked', 'false');
}
}
}
}
The property setter reflects to the attribute, and attributeChangedCallback updates the internal DOM.
Complex Property Types
Handle non-string properties carefully:
class DataTable extends HTMLElement {
static get observedAttributes() { return ['data']; }
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._data = [];
}
// Use a getter/setter pair for objects
get data() { return this._data; }
set data(value) {
this._data = Array.isArray(value) ? value : [];
this._render();
}
// Attributes contain JSON
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'data' && oldValue !== newValue) {
try {
this._data = JSON.parse(newValue);
this._render();
} catch (e) {
console.error('Invalid JSON in data attribute');
}
}
}
_render() {
// Render table from this._data
}
}
This pattern allows both attribute-based and property-based usage:
<data-table data='[{"name": "Alice"}]'></data-table>
const table = document.querySelector('data-table');
table.data = [{ name: 'Alice' }, { name: 'Bob' }];
Slots and Composition
Slots enable flexible component composition, allowing users to inject content into your components.
Named Slots
Multiple slots provide flexible content areas:
class CardComponent extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
}
.header {
background: #f5f5f5;
padding: 12px 16px;
border-bottom: 1px solid #ddd;
}
.body {
padding: 16px;
}
.footer {
padding: 12px 16px;
border-top: 1px solid #ddd;
background: #fafafa;
}
slot[name="header"]::slotted(h2) {
margin: 0;
font-size: 1.25rem;
}
</style>
<div class="header">
<slot name="header">
<h2>Default Title</h2>
</slot>
</div>
<div class="body">
<slot></slot>
</div>
<div class="footer">
<slot name="footer"></slot>
</div>
`;
}
}
Usage:
<card-component>
<h2 slot="header">My Card</h2>
<p>This is the main content.</p>
<p>It can contain any elements.</p>
<button slot="footer">Action</button>
</card-component>
Default Content
Slots provide default content when nothing is provided:
class AlertBox extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
.alert {
padding: 12px 16px;
border-radius: 4px;
}
:host([type="info"]) .alert { background: #e3f2fd; color: #0d47a1; }
:host([type="warning"]) .alert { background: #fff3e0; color: #e65100; }
:host([type="error"]) .alert { background: #ffebee; color: #b71c1c; }
</style>
<div class="alert">
<slot>
<strong>Notice:</strong> Default alert message.
</slot>
</div>
`;
}
}
Slot Events
Listen for slot changes:
class SlotWatcher extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<slot></slot>
`;
}
connectedCallback() {
const slot = this.shadowRoot.querySelector('slot');
slot.addEventListener('slotchange', (e) => {
const nodes = e.target.assignedElements();
console.log(`Slot changed, now has ${nodes.length} elements`);
});
}
}
The slotchange event fires when projected content changes.
Styling Components
Styling Web Components requires understanding the interaction between shadow and light DOM styles.
CSS Custom Properties
Pass styles through with CSS variables:
class ThemedButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
button {
background: var(--btn-bg, #0066cc);
color: var(--btn-color, white);
border: var(--btn-border, none);
padding: var(--btn-padding, 8px 16px);
border-radius: var(--btn-radius, 4px);
font-size: var(--btn-font-size, 14px);
}
</style>
<button><slot></slot></button>
`;
}
}
<themed-button>Default</themed-button>
<themed-button style="
--btn-bg: #28a745;
--btn-radius: 8px;
">
Custom Theme
</themed-button>
CSS custom properties pierce shadow DOM, making them perfect for theming.
:host Selectors
The :host pseudo-class targets the component element itself:
class PositionedElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
position: absolute;
}
:host([position="top"]) { top: 0; }
:host([position="bottom"]) { bottom: 0; }
:host([position="left"]) { left: 0; }
:host([position="right"]) { right: 0; }
:host([hidden]) {
display: none;
}
</style>
<slot></slot>
`;
}
}
Other host selectors include :host(), :host-context() for parent-based styling.
Constructable Stylesheets
Share styles efficiently with constructable stylesheets:
const sharedStyles = new CSSStyleSheet();
sharedStyles.replace(`
.common {
font-family: system-ui, sans-serif;
line-height: 1.5;
}
.highlight {
background: yellow;
}
`);
class StyledComponent extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.adoptedStyleSheets = [sharedStyles];
shadow.innerHTML = '<div class="common highlight">Content</div>';
}
}
Shared stylesheets reduce memory usage when components appear multiple times.
Form Participation
Making components participate in forms requires specific techniques.
Native Form Integration
Custom elements can participate in forms natively:
class FancyInput extends HTMLElement {
static get formAssociated() { return true; }
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._internals = this.attachInternals();
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
input {
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
}
</style>
<input type="text" id="input">
`;
this._input = this.shadowRoot.getElementById('input');
this._input.addEventListener('input', () => {
this._internals.setFormValue(this._input.value);
});
}
get value() { return this._input.value; }
set value(v) { this._input.value = v; }
get validity() { return this._internals.validity; }
get validationMessage() { return this._internals.validationMessage; }
get willValidate() { return this._internals.willValidate; }
checkValidity() { return this._internals.checkValidity(); }
reportValidity() { return this._internals.reportValidity(); }
}
The attachInternals() method enables form participation with validation, error states, and disabled behavior.
Form Reset
Handle form reset:
class FancyInput extends HTMLElement {
// ... previous code ...
formResetCallback() {
this._input.value = '';
this._internals.setFormValue('');
}
}
The formResetCallback fires when the parent form resets.
Advanced Patterns
Beyond basics, advanced patterns enable sophisticated components.
Reactive Components
Build reactive state management:
class ReactiveCounter extends HTMLElement {
#state = { count: 0 };
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._render();
}
get count() { return this.#state.count; }
set count(value) {
this.#state.count = value;
this._render();
}
increment() {
this.count++;
}
_render() {
this.shadowRoot.innerHTML = `
<style>
button { padding: 8px 16px; }
</style>
<div>
Count: ${this.#state.count}
<button id="inc">+</button>
</div>
`;
this.shadowRoot.getElementById('inc')
.addEventListener('click', () => this.increment());
}
}
For more complex reactivity, consider reactive libraries like @lit-labs/react or build your own.
Intersection Observer
Detect visibility:
class LazyImage extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
img { max-width: 100%; }
</style>
<img id="img" loading="lazy">
`;
}
connectedCallback() {
this._observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this._loadImage();
this._observer.disconnect();
}
});
});
this._observer.observe(this);
}
disconnectedCallback() {
this._observer.disconnect();
}
_loadImage() {
const src = this.getAttribute('src');
if (src) {
this.shadowRoot.getElementById('img').src = src;
}
}
}
ResizeObserver
Respond to size changes:
class ResponsiveContainer extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<slot></slot>
`;
}
connectedCallback() {
this._resizeObserver = new ResizeObserver(entries => {
entries.forEach(entry => {
this.dispatchEvent(new CustomEvent('resize', {
detail: {
width: entry.contentRect.width,
height: entry.contentRect.height
}
}));
});
});
this._resizeObserver.observe(this);
}
disconnectedCallback() {
this._resizeObserver.disconnect();
}
}
Using with Frameworks
Web Components work alongside any framework.
React Integration
React can render Web Components with special handling:
// For components with properties
<FancyInput
ref={inputRef}
value={state}
onInput={(e) => setState(e.target.value)}
// Use onChange for value changes
onChange={(e) => setState(e.target.value)}
/>
// Register custom elements
import { useEffect } from 'react';
function useCustomElement(name, componentClass) {
useEffect(() => {
if (!customElements.get(name)) {
customElements.define(name, componentClass);
}
}, [name, componentClass]);
}
Vue Integration
Vue handles Web Components seamlessly:
<template>
<fancy-input
v-model="value"
@action="handleAction"
/>
</template>
<script>
export default {
data() {
return { value: '' }
},
methods: {
handleAction(e) {
console.log('Action triggered', e);
}
}
}
</script>
Angular Integration
Angular requires schema configuration:
import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
@NgModule({
declarations: [MyComponent],
imports: [CommonModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class MyModule { }
Best Practices
Building production-ready components requires following established patterns.
Accessibility
Always prioritize accessibility:
class AccessibleMenu extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
[role="menu"] { border: 1px solid #ccc; }
[role="menuitem"] { padding: 8px; }
[role="menuitem"]:focus { background: #0066cc; color: white; }
</style>
<div role="menu" tabindex="0">
<slot></slot>
</div>
`;
}
connectedCallback() {
this.setAttribute('role', 'menubar');
this.tabIndex = 0;
}
}
Use ARIA roles, keyboard navigation, and focus management.
Performance
Optimize rendering:
class OptimizedComponent extends HTMLElement {
#rendered = false;
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
if (!this.#rendered) {
this._render();
this.#rendered = true;
}
}
// Use attributeChangedCallback for targeted updates
attributeChangedCallback(name, old, value) {
if (old !== value) {
this._updateAttribute(name, value);
}
}
}
Documentation
Document your components:
/**
* A button component with customizable variants.
*
* @element fancy-button
*
* @fires click - Fired when button is clicked
*
* @slot - The button text
*
* @cssprop --btn-bg - Background color
* @cssprop --btn-color - Text color
*
* @example
* <fancy-button variant="primary">Click Me</fancy-button>
*/
class FancyButton extends HTMLElement { }
Use JSDoc comments that tools can consume.
Conclusion
Web Components represent the web platform’s commitment to native, standards-based component architecture. They provide a stable foundation for building reusable UI elements that transcend framework boundaries and stand the test of time.
The key to success lies in understanding the platform: embrace shadow DOM for encapsulation, use slots for flexibility, implement proper lifecycle management, and prioritize accessibility. With these fundamentals, you can build components that work anywhere and last indefinitely.
As frameworks continue to evolve and consolidate around web standards, Web Components become increasingly valuable. They represent the one investment in UI architecture that never needs to be rewritten. Build your components right, and they’ll serve your applications for years to come.
Comments