Building Modern Web Components: A 2025 Developer's Guide
Introduction
Have you ever built the same dropdown menu, modal dialog, or custom button three different ways for React, Vue, and Angular projects? If you’re nodding along, you’ve experienced one of modern web development’s most frustrating challenges: component reusability across frameworks.
Web Components offer an elegant solution to this problem. They’re a suite of native browser technologies that let you create custom, reusable HTML elements that work everywhere—no framework required. Think of them as LEGO blocks for the web: build once, use anywhere.
In this guide, you’ll learn how to create Web Components from scratch, understand when to use them, and discover the patterns that separate good components from great ones. We’ll build practical examples, explore the latest 2024-2025 developments like Declarative Shadow DOM, and tackle common pitfalls that trip up even experienced developers.
Prerequisites
Before diving in, you should have:
- Solid JavaScript fundamentals - Understanding of ES6 classes, modules, and async/await
- HTML/CSS knowledge - Familiarity with the DOM and CSS selectors
- Basic command line skills - For running examples locally
- Modern browser - Chrome 54+, Firefox 63+, Safari 10.1+, or Edge 79+ (all major browsers now support Web Components)
- Text editor - VS Code, Sublime, or your preferred editor
- Local development server - Optional but recommended (e.g.,
npx serveor VS Code Live Server)
No framework knowledge required—that’s the beauty of Web Components!
Core Concepts: The Three Pillars
Web Components aren’t a single technology but three complementary standards working together:
Custom Elements
Custom Elements allow you to define new HTML tags with custom behavior. They’re the foundation of Web Components.
// Define a simple custom element
class HelloWorld extends HTMLElement {
connectedCallback() {
this.innerHTML = '<h1>Hello, World!</h1>';
}
}
// Register the custom element
customElements.define('hello-world', HelloWorld);
Now you can use <hello-world></hello-world> anywhere in your HTML, just like a native element.
Key requirements:
- Element names must contain a hyphen (e.g.,
my-element, notmyelement) - They must extend
HTMLElementor another HTML interface - Registration is done via
customElements.define()
Shadow DOM
Shadow DOM provides encapsulation—a way to keep your component’s styles and markup separate from the rest of the page.
class EncapsulatedButton extends HTMLElement {
connectedCallback() {
// Attach a shadow root
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
button {
background: #6200ea;
color: white;
border: none;
padding: 12px 24px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
button:hover {
background: #3700b3;
}
</style>
<button>
<slot></slot>
</button>
`;
}
}
customElements.define('encapsulated-button', EncapsulatedButton);
The styles defined inside the Shadow DOM won’t leak out to the page, and external styles won’t leak in. Use it like this:
<encapsulated-button>Click Me</encapsulated-button>
HTML Templates
Templates define reusable markup that isn’t rendered until you explicitly clone and insert it.
<template id="user-card-template">
<style>
.card {
border: 1px solid #ddd;
padding: 16px;
border-radius: 8px;
max-width: 300px;
}
.card h3 {
margin-top: 0;
color: #333;
}
</style>
<div class="card">
<h3 class="name"></h3>
<p class="email"></p>
</div>
</template>
<script>
class UserCard extends HTMLElement {
connectedCallback() {
const template = document.getElementById('user-card-template');
const content = template.content.cloneNode(true);
content.querySelector('.name').textContent = this.getAttribute('name');
content.querySelector('.email').textContent = this.getAttribute('email');
this.attachShadow({ mode: 'open' }).appendChild(content);
}
}
customElements.define('user-card', UserCard);
</script>
Practical Implementation: Building a Toast Notification Component
Let’s build something useful—a toast notification component that demonstrates key Web Components patterns.
Step 1: Define the Component Structure
// toast-notification.js
class ToastNotification extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
static get observedAttributes() {
return ['type', 'duration', 'message'];
}
connectedCallback() {
this.render();
this.setupAutoHide();
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this.render();
}
}
render() {
const type = this.getAttribute('type') || 'info';
const message = this.getAttribute('message') || 'Notification';
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000;
animation: slideIn 0.3s ease-out;
}
:host([hidden]) {
display: none;
}
.toast {
background: var(--toast-bg, #333);
color: var(--toast-color, white);
padding: 16px 24px;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 250px;
display: flex;
align-items: center;
gap: 12px;
}
.toast.success {
background: #4caf50;
}
.toast.error {
background: #f44336;
}
.toast.warning {
background: #ff9800;
}
.close-btn {
background: none;
border: none;
color: inherit;
cursor: pointer;
font-size: 20px;
padding: 0;
margin-left: auto;
}
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
</style>
<div class="toast ${type}">
<span class="message">${message}</span>
<button class="close-btn" aria-label="Close">×</button>
</div>
`;
this.shadowRoot.querySelector('.close-btn').addEventListener('click', () => {
this.hide();
});
}
setupAutoHide() {
const duration = parseInt(this.getAttribute('duration')) || 3000;
if (duration > 0) {
setTimeout(() => this.hide(), duration);
}
}
hide() {
this.dispatchEvent(new CustomEvent('toast-hidden', {
bubbles: true,
composed: true
}));
this.remove();
}
show() {
this.removeAttribute('hidden');
}
}
customElements.define('toast-notification', ToastNotification);
Step 2: Using the Toast Component
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Toast Notification Demo</title>
<script src="toast-notification.js"></script>
</head>
<body>
<h1>Toast Notification Example</h1>
<button onclick="showToast('success')">Show Success</button>
<button onclick="showToast('error')">Show Error</button>
<button onclick="showToast('warning')">Show Warning</button>
<script>
function showToast(type) {
const toast = document.createElement('toast-notification');
toast.setAttribute('type', type);
toast.setAttribute('message', `This is a ${type} message!`);
toast.setAttribute('duration', '3000');
toast.addEventListener('toast-hidden', () => {
console.log('Toast was dismissed');
});
document.body.appendChild(toast);
}
</script>
</body>
</html>
Component Architecture Visualization
Advanced Topics: Lifecycle Callbacks
Web Components have four lifecycle callbacks that give you precise control:
connectedCallback
Called when the element is inserted into the DOM.
connectedCallback() {
console.log('Component added to page');
this.render();
this.setupEventListeners();
}
Use for: Initial rendering, setting up event listeners, fetching data
disconnectedCallback
Called when the element is removed from the DOM.
disconnectedCallback() {
console.log('Component removed from page');
// Clean up: remove event listeners, cancel timers
if (this.intervalId) {
clearInterval(this.intervalId);
}
}
Use for: Cleanup to prevent memory leaks
attributeChangedCallback
Called when observed attributes change.
static get observedAttributes() {
return ['status', 'count'];
}
attributeChangedCallback(name, oldValue, newValue) {
console.log(`Attribute ${name} changed from ${oldValue} to ${newValue}`);
if (name === 'status') {
this.updateStatus(newValue);
}
}
Use for: Reacting to attribute changes
adoptedCallback
Called when the element is moved to a new document (rare).
adoptedCallback() {
console.log('Component moved to new document');
}
Working with Slots and Light DOM
Slots allow users of your component to provide their own content while maintaining your component’s structure and styling.
class CardComponent extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
.card {
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 16px;
background: white;
}
.header {
font-size: 20px;
font-weight: bold;
margin-bottom: 12px;
color: #333;
}
.content {
color: #666;
line-height: 1.6;
}
.footer {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #e0e0e0;
}
</style>
<div class="card">
<div class="header">
<slot name="header">Default Header</slot>
</div>
<div class="content">
<slot>Default content</slot>
</div>
<div class="footer">
<slot name="footer"></slot>
</div>
</div>
`;
}
}
customElements.define('card-component', CardComponent);
Usage with named slots:
<card-component>
<span slot="header">User Profile</span>
<p>This is the main content area where you can put anything.</p>
<p>Multiple elements work too!</p>
<div slot="footer">
<button>Edit</button>
<button>Delete</button>
</div>
</card-component>
Declarative Shadow DOM: The 2024 Game-Changer
As of February 2024, Declarative Shadow DOM (DSD) achieved full cross-browser support. This allows server-side rendering of Web Components without JavaScript.
<my-component>
<template shadowrootmode="open">
<style>
:host {
display: block;
padding: 20px;
border: 2px solid #6200ea;
}
</style>
<h2>Server-Rendered Shadow DOM</h2>
<p>This content is available immediately, before JavaScript loads!</p>
</template>
</my-component>
This solves the Flash of Unstyled Custom Elements (FOUCE) problem and enables progressive enhancement—the component works with basic functionality even if JavaScript fails to load.
Common Pitfalls and Troubleshooting
Problem 1: Form Elements Don’t Work in Shadow DOM
Issue: Input elements inside Shadow DOM aren’t included in form submissions.
Solution: Use the ElementInternals API (Chrome/Edge) or emit custom events:
class CustomInput extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
input {
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
}
</style>
<input type="text" />
`;
const input = this.shadowRoot.querySelector('input');
// Forward input events
input.addEventListener('input', (e) => {
this.dispatchEvent(new CustomEvent('value-changed', {
detail: { value: e.target.value },
bubbles: true,
composed: true
}));
});
}
get value() {
return this.shadowRoot.querySelector('input').value;
}
set value(val) {
this.shadowRoot.querySelector('input').value = val;
}
}
customElements.define('custom-input', CustomInput);
Problem 2: Styles Not Working as Expected
Issue: Global styles don’t penetrate Shadow DOM, or component styles leak out.
Solution: Use CSS custom properties (variables) which do penetrate Shadow DOM:
// In component
shadowRoot.innerHTML = `
<style>
button {
background: var(--primary-color, #6200ea);
color: var(--text-color, white);
}
</style>
<button><slot></slot></button>
`;
/* In global CSS */
:root {
--primary-color: #ff5722;
--text-color: white;
}
Problem 3: Component Name Collisions
Issue: Two libraries define the same custom element name.
Current Status: Scoped Custom Element Registry is in discussion but not yet standardized.
Workaround: Use unique prefixes for your components:
// Instead of <button-component>
customElements.define('mylib-button', ButtonComponent);
Problem 4: Memory Leaks
Issue: Event listeners or timers not cleaned up.
Solution: Always clean up in disconnectedCallback:
class TimerComponent extends HTMLElement {
connectedCallback() {
this.intervalId = setInterval(() => {
this.updateTime();
}, 1000);
}
disconnectedCallback() {
// Critical: prevent memory leak
if (this.intervalId) {
clearInterval(this.intervalId);
}
}
updateTime() {
this.textContent = new Date().toLocaleTimeString();
}
}
Problem 5: React Event Binding
Issue: React doesn’t automatically bind to custom events from Web Components.
Solution: Use refs and addEventListener:
import { useEffect, useRef } from 'react';
function App() {
const toastRef = useRef(null);
useEffect(() => {
const handleToastHidden = (event) => {
console.log('Toast hidden:', event.detail);
};
const element = toastRef.current;
if (element) {
element.addEventListener('toast-hidden', handleToastHidden);
return () => {
element.removeEventListener('toast-hidden', handleToastHidden);
};
}
}, []);
return <toast-notification ref={toastRef} type="success" />;
}
Best Practices for Production
1. Keep Components Simple and Focused
Each component should do one thing well. Don’t create monolithic components.
// Good: Single responsibility
class DatePicker extends HTMLElement { /* ... */ }
// Bad: Too many responsibilities
class FormBuilder extends HTMLElement { /* handles everything */ }
2. Use Semantic HTML Inside Components
Even in Shadow DOM, accessibility matters:
// Good
shadowRoot.innerHTML = `
<button role="button" aria-label="Close notification">
×
</button>
`;
// Bad
shadowRoot.innerHTML = `
<div class="clickable">×</div>
`;
3. Document Your Component API
/**
* Toast Notification Component
*
* Attributes:
* - type: 'info' | 'success' | 'error' | 'warning'
* - message: string - The notification message
* - duration: number - Auto-hide duration in ms (0 = no auto-hide)
*
* Events:
* - toast-hidden: Fired when the toast is dismissed
*
* Methods:
* - show(): Display the toast
* - hide(): Dismiss the toast
*
* CSS Custom Properties:
* - --toast-bg: Background color
* - --toast-color: Text color
*/
class ToastNotification extends HTMLElement { /* ... */ }
4. Test Thoroughly
// Example test with a testing library
describe('ToastNotification', () => {
it('should display the correct message', () => {
const toast = document.createElement('toast-notification');
toast.setAttribute('message', 'Test message');
document.body.appendChild(toast);
const message = toast.shadowRoot.querySelector('.message');
expect(message.textContent).toBe('Test message');
document.body.removeChild(toast);
});
it('should emit event when closed', (done) => {
const toast = document.createElement('toast-notification');
toast.addEventListener('toast-hidden', () => {
done();
});
document.body.appendChild(toast);
toast.hide();
});
});
5. Optimize for Performance
class OptimizedComponent extends HTMLElement {
connectedCallback() {
// Use requestAnimationFrame for rendering
requestAnimationFrame(() => {
this.render();
});
}
render() {
// Cache DOM queries
if (!this._cachedElement) {
this._cachedElement = this.shadowRoot.querySelector('.target');
}
// Update efficiently
this._cachedElement.textContent = this.getAttribute('text');
}
}
When to Use Web Components vs. Framework Components
Use Web Components When:
- Building a design system that needs to work across multiple frameworks
- Creating shareable widgets or embeddable components
- Working on long-term projects where framework trends may change
- Building progressive enhancement features
- Developing CMS plugins or third-party integrations
Use Framework Components When:
- Building a single-page application with one framework
- Need tight integration with framework-specific features (React Context, Vue reactivity)
- Working with complex state management patterns
- Team expertise is heavily framework-focused
- Need server-side rendering with full hydration (though DSD is improving this)
The Hybrid Approach
Many teams use both: Web Components for the design system, framework components for application logic.
// Web Component: Reusable button
class DesignButton extends HTMLElement { /* ... */ }
// React Component: App-specific logic
function CheckoutButton() {
const handleClick = () => {
// Complex app logic
processCheckout();
};
return <design-button onClick={handleClick}>Checkout</design-button>;
}
Conclusion
Web Components represent a fundamental shift in how we think about reusable UI elements on the web. They’re not replacing React, Vue, or Angular—they’re complementing them by providing a framework-agnostic foundation that will outlast any particular library’s popularity.
The key takeaways:
- Web Components are production-ready with full cross-browser support as of 2024
- Declarative Shadow DOM solves the SSR challenge
- Best for design systems and cross-framework component libraries
- Require careful attention to forms, accessibility, and lifecycle management
- Work alongside frameworks, not instead of them
As you build your next project, consider whether Web Components might solve your reusability challenges. Start small—maybe with a single shared component—and expand from there. The web platform continues to evolve, and Web Components are a bet on the open web that’s likely to pay dividends for years to come.
Ready to build your first Web Component? Start with something simple like a tooltip or badge, then gradually tackle more complex patterns. The patterns you learn will serve you regardless of which frameworks come and go.
References:
- MDN Web Components Documentation - https://developer.mozilla.org/en-US/docs/Web/API/Web_components - Comprehensive official documentation with examples
- Web Components Community Group Updates - https://eisenbergeffect.medium.com/web-components-2024-winter-update-445f27e7613a - Latest standards and Declarative Shadow DOM developments
- WebComponents.org Best Practices - https://webcomponents.github.io/articles/web-components-best-practices/ - Community-vetted best practices and patterns
- Kinsta Web Components Guide - https://kinsta.com/blog/web-components/ - Practical introduction with real-world examples
- The Design System Guide - https://thedesignsystem.guide/knowledge-base/web-component-libraries-and-patterns - Enterprise patterns and performance considerations