JavaScript Web Components: Custom Elements, Shadow DOM, and HTML Templates
Master Web Components with JavaScript. Learn to build reusable custom elements, encapsulated styling with Shadow DOM, and dynamic templates.
Web Components are a set of web platform APIs that allow you to create reusable, encapsulated HTML elements. They consist of four main technologies: Custom Elements, Shadow DOM, HTML Templates, and HTML Imports. This comprehensive guide covers building robust, reusable components using modern JavaScript.
Web Components Fundamentals
Custom Elements API
Custom Elements allow you to define new HTML tags with custom behavior.
// Basic Custom Element
class SimpleButton extends HTMLElement {
constructor() {
super();
// Set up initial state
this.state = {
disabled: false,
variant: 'primary',
size: 'medium',
};
// Bind methods to maintain context
this.handleClick = this.handleClick.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
}
// Lifecycle callback - element added to DOM
connectedCallback() {
this.render();
this.setupEventListeners();
this.setupAttributes();
}
// Lifecycle callback - element removed from DOM
disconnectedCallback() {
this.cleanup();
}
// Lifecycle callback - attributes changed
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this.handleAttributeChange(name, newValue);
}
}
// Lifecycle callback - element moved to new document
adoptedCallback() {
this.render();
}
// Define which attributes to observe
static get observedAttributes() {
return ['disabled', 'variant', 'size', 'type'];
}
// Handle attribute changes
handleAttributeChange(name, value) {
switch (name) {
case 'disabled':
this.state.disabled = value !== null;
this.updateDisabledState();
break;
case 'variant':
this.state.variant = value || 'primary';
this.updateVariant();
break;
case 'size':
this.state.size = value || 'medium';
this.updateSize();
break;
case 'type':
this.updateType(value);
break;
}
}
// Setup initial attributes
setupAttributes() {
// Set default attributes if not present
if (!this.hasAttribute('variant')) {
this.setAttribute('variant', 'primary');
}
if (!this.hasAttribute('size')) {
this.setAttribute('size', 'medium');
}
if (!this.hasAttribute('type')) {
this.setAttribute('type', 'button');
}
}
// Render the component
render() {
this.innerHTML = `
<style>
:host {
display: inline-block;
font-family: system-ui, -apple-system, sans-serif;
}
button {
border: none;
border-radius: 4px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
outline: none;
font-family: inherit;
}
button:focus {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Variants */
.primary {
background: #3b82f6;
color: white;
}
.primary:hover:not(:disabled) {
background: #2563eb;
}
.secondary {
background: #6b7280;
color: white;
}
.secondary:hover:not(:disabled) {
background: #4b5563;
}
.outline {
background: transparent;
border: 1px solid #d1d5db;
color: #374151;
}
.outline:hover:not(:disabled) {
background: #f9fafb;
}
/* Sizes */
.small {
padding: 6px 12px;
font-size: 12px;
}
.medium {
padding: 8px 16px;
font-size: 14px;
}
.large {
padding: 12px 24px;
font-size: 16px;
}
</style>
<button class="${this.state.variant} ${this.state.size}">
<slot></slot>
</button>
`;
this.button = this.querySelector('button');
}
// Set up event listeners
setupEventListeners() {
if (this.button) {
this.button.addEventListener('click', this.handleClick);
this.button.addEventListener('keydown', this.handleKeyDown);
}
}
// Handle click events
handleClick(event) {
if (this.state.disabled) {
event.preventDefault();
event.stopPropagation();
return;
}
// Dispatch custom event
this.dispatchEvent(
new CustomEvent('simple-button-click', {
detail: {
variant: this.state.variant,
size: this.state.size,
timestamp: Date.now(),
},
bubbles: true,
composed: true,
})
);
}
// Handle keyboard events
handleKeyDown(event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.handleClick(event);
}
}
// Update disabled state
updateDisabledState() {
if (this.button) {
this.button.disabled = this.state.disabled;
}
}
// Update variant
updateVariant() {
if (this.button) {
this.button.className = `${this.state.variant} ${this.state.size}`;
}
}
// Update size
updateSize() {
if (this.button) {
this.button.className = `${this.state.variant} ${this.state.size}`;
}
}
// Update type
updateType(type) {
if (this.button) {
this.button.type = type || 'button';
}
}
// Cleanup method
cleanup() {
if (this.button) {
this.button.removeEventListener('click', this.handleClick);
this.button.removeEventListener('keydown', this.handleKeyDown);
}
}
// Public API methods
focus() {
if (this.button) {
this.button.focus();
}
}
blur() {
if (this.button) {
this.button.blur();
}
}
disable() {
this.setAttribute('disabled', '');
}
enable() {
this.removeAttribute('disabled');
}
}
// Register the custom element
customElements.define('simple-button', SimpleButton);
// Usage examples
document.body.innerHTML += `
<simple-button variant="primary" size="large">Primary Button</simple-button>
<simple-button variant="secondary">Secondary Button</simple-button>
<simple-button variant="outline" size="small">Outline Button</simple-button>
<simple-button disabled>Disabled Button</simple-button>
`;
// Listen for custom events
document.addEventListener('simple-button-click', (event) => {
console.log('Button clicked:', event.detail);
});
Shadow DOM
Shadow DOM provides encapsulation for your component's internal structure and styling.
// Advanced Component with Shadow DOM
class AdvancedCard extends HTMLElement {
constructor() {
super();
// Create shadow root for encapsulation
this.attachShadow({ mode: 'open' });
// Component state
this.state = {
expanded: false,
loading: false,
data: null,
};
// Bind methods
this.toggleExpanded = this.toggleExpanded.bind(this);
this.handleSlotChange = this.handleSlotChange.bind(this);
}
connectedCallback() {
this.render();
this.setupEventListeners();
this.setupSlotObserver();
}
disconnectedCallback() {
this.cleanup();
}
static get observedAttributes() {
return ['title', 'subtitle', 'expanded', 'loading'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this.handleAttributeChange(name, newValue);
}
}
handleAttributeChange(name, value) {
switch (name) {
case 'title':
this.updateTitle(value);
break;
case 'subtitle':
this.updateSubtitle(value);
break;
case 'expanded':
this.state.expanded = value !== null;
this.updateExpandedState();
break;
case 'loading':
this.state.loading = value !== null;
this.updateLoadingState();
break;
}
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
font-family: system-ui, -apple-system, sans-serif;
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
transition: box-shadow 0.2s ease;
}
:host(:hover) {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
:host([loading]) {
opacity: 0.7;
pointer-events: none;
}
.header {
padding: 16px;
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
}
.header:hover {
background: #f3f4f6;
}
.title-section {
flex: 1;
}
.title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #111827;
}
.subtitle {
margin: 4px 0 0 0;
font-size: 14px;
color: #6b7280;
}
.expand-icon {
width: 20px;
height: 20px;
transition: transform 0.2s ease;
color: #6b7280;
}
.expand-icon.expanded {
transform: rotate(180deg);
}
.content {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.content.expanded {
max-height: 500px;
}
.content-inner {
padding: 16px;
}
.loading-spinner {
display: none;
width: 20px;
height: 20px;
border: 2px solid #e5e7eb;
border-top: 2px solid #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
:host([loading]) .loading-spinner {
display: block;
}
:host([loading]) .expand-icon {
display: none;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Slot styling */
::slotted([slot="actions"]) {
margin-top: 12px;
display: flex;
gap: 8px;
}
::slotted([slot="footer"]) {
border-top: 1px solid #e5e7eb;
padding: 12px 16px;
background: #f9fafb;
font-size: 12px;
color: #6b7280;
}
</style>
<div class="header" part="header">
<div class="title-section">
<h3 class="title" part="title">${this.getAttribute('title') || ''}</h3>
<p class="subtitle" part="subtitle">${this.getAttribute('subtitle') || ''}</p>
</div>
<div class="loading-spinner"></div>
<svg class="expand-icon ${this.state.expanded ? 'expanded' : ''}"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 9l-7 7-7-7"></path>
</svg>
</div>
<div class="content ${this.state.expanded ? 'expanded' : ''}" part="content">
<div class="content-inner">
<slot></slot>
<slot name="actions"></slot>
</div>
</div>
<slot name="footer"></slot>
`;
}
setupEventListeners() {
const header = this.shadowRoot.querySelector('.header');
if (header) {
header.addEventListener('click', this.toggleExpanded);
}
}
setupSlotObserver() {
// Monitor slot changes
const slots = this.shadowRoot.querySelectorAll('slot');
slots.forEach((slot) => {
slot.addEventListener('slotchange', this.handleSlotChange);
});
}
handleSlotChange(event) {
const slot = event.target;
const assignedNodes = slot.assignedNodes();
// Dispatch event when slot content changes
this.dispatchEvent(
new CustomEvent('slot-change', {
detail: {
slot: slot.name || 'default',
nodeCount: assignedNodes.length,
},
bubbles: true,
})
);
}
toggleExpanded() {
if (this.state.loading) return;
this.state.expanded = !this.state.expanded;
if (this.state.expanded) {
this.setAttribute('expanded', '');
} else {
this.removeAttribute('expanded');
}
// Dispatch custom event
this.dispatchEvent(
new CustomEvent('card-toggle', {
detail: {
expanded: this.state.expanded,
title: this.getAttribute('title'),
},
bubbles: true,
})
);
}
updateTitle(title) {
const titleElement = this.shadowRoot.querySelector('.title');
if (titleElement) {
titleElement.textContent = title || '';
}
}
updateSubtitle(subtitle) {
const subtitleElement = this.shadowRoot.querySelector('.subtitle');
if (subtitleElement) {
subtitleElement.textContent = subtitle || '';
subtitleElement.style.display = subtitle ? 'block' : 'none';
}
}
updateExpandedState() {
const content = this.shadowRoot.querySelector('.content');
const expandIcon = this.shadowRoot.querySelector('.expand-icon');
if (content) {
content.classList.toggle('expanded', this.state.expanded);
}
if (expandIcon) {
expandIcon.classList.toggle('expanded', this.state.expanded);
}
}
updateLoadingState() {
// Loading state is handled via CSS and :host([loading]) selector
}
cleanup() {
const header = this.shadowRoot.querySelector('.header');
if (header) {
header.removeEventListener('click', this.toggleExpanded);
}
const slots = this.shadowRoot.querySelectorAll('slot');
slots.forEach((slot) => {
slot.removeEventListener('slotchange', this.handleSlotChange);
});
}
// Public API
expand() {
if (!this.state.expanded) {
this.toggleExpanded();
}
}
collapse() {
if (this.state.expanded) {
this.toggleExpanded();
}
}
setLoading(loading) {
if (loading) {
this.setAttribute('loading', '');
} else {
this.removeAttribute('loading');
}
}
updateContent(content) {
const defaultSlot = this.querySelector(':not([slot])');
if (defaultSlot) {
defaultSlot.innerHTML = content;
}
}
}
// Register the advanced card component
customElements.define('advanced-card', AdvancedCard);
// Usage example
document.body.innerHTML += `
<advanced-card title="Advanced Card" subtitle="With Shadow DOM">
<p>This content is projected into the card via slots.</p>
<p>The styling is completely encapsulated.</p>
<div slot="actions">
<button>Action 1</button>
<button>Action 2</button>
</div>
<div slot="footer">
Last updated: ${new Date().toLocaleDateString()}
</div>
</advanced-card>
`;
HTML Templates
HTML Templates provide a way to define reusable markup structures.
// Template-based Component System
class TemplateManager {
static templates = new Map();
// Register a template
static registerTemplate(name, templateString) {
const template = document.createElement('template');
template.innerHTML = templateString;
this.templates.set(name, template);
}
// Get a template
static getTemplate(name) {
return this.templates.get(name);
}
// Clone template content
static cloneTemplate(name) {
const template = this.getTemplate(name);
return template ? template.content.cloneNode(true) : null;
}
// Initialize with common templates
static init() {
// Card template
this.registerTemplate(
'card',
`
<style>
.card {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 16px;
margin: 8px 0;
background: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.card-header {
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #e5e7eb;
}
.card-title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.card-body {
margin: 12px 0;
}
.card-footer {
margin-top: 12px;
padding-top: 8px;
border-top: 1px solid #e5e7eb;
font-size: 12px;
color: #6b7280;
}
</style>
<div class="card" part="card">
<header class="card-header" part="header">
<h3 class="card-title" part="title"></h3>
</header>
<div class="card-body" part="body">
<slot></slot>
</div>
<footer class="card-footer" part="footer">
<slot name="footer"></slot>
</footer>
</div>
`
);
// Form field template
this.registerTemplate(
'form-field',
`
<style>
.form-field {
margin-bottom: 16px;
}
.form-label {
display: block;
margin-bottom: 4px;
font-weight: 500;
font-size: 14px;
color: #374151;
}
.form-input {
width: 100%;
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 14px;
transition: border-color 0.2s ease;
}
.form-input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 1px #3b82f6;
}
.form-error {
margin-top: 4px;
font-size: 12px;
color: #ef4444;
}
.form-help {
margin-top: 4px;
font-size: 12px;
color: #6b7280;
}
</style>
<div class="form-field" part="field">
<label class="form-label" part="label"></label>
<input class="form-input" part="input">
<div class="form-error" part="error" style="display: none;"></div>
<div class="form-help" part="help"></div>
</div>
`
);
// Modal template
this.registerTemplate(
'modal',
`
<style>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
}
.modal-overlay.open {
opacity: 1;
visibility: visible;
}
.modal-content {
background: white;
border-radius: 8px;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
transform: scale(0.9);
transition: transform 0.3s ease;
}
.modal-overlay.open .modal-content {
transform: scale(1);
}
.modal-header {
padding: 20px;
border-bottom: 1px solid #e5e7eb;
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.modal-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #6b7280;
}
.modal-body {
padding: 20px;
}
.modal-footer {
padding: 20px;
border-top: 1px solid #e5e7eb;
display: flex;
gap: 8px;
justify-content: flex-end;
}
</style>
<div class="modal-overlay" part="overlay">
<div class="modal-content" part="content">
<header class="modal-header" part="header">
<h2 class="modal-title" part="title"></h2>
<button class="modal-close" part="close">×</button>
</header>
<div class="modal-body" part="body">
<slot></slot>
</div>
<footer class="modal-footer" part="footer">
<slot name="footer"></slot>
</footer>
</div>
</div>
`
);
}
}
// Initialize templates
TemplateManager.init();
// Template-based Card Component
class TemplateCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
this.setupAttributes();
}
static get observedAttributes() {
return ['title'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'title' && oldValue !== newValue) {
this.updateTitle(newValue);
}
}
render() {
const template = TemplateManager.cloneTemplate('card');
if (template) {
this.shadowRoot.appendChild(template);
}
}
setupAttributes() {
const title = this.getAttribute('title');
if (title) {
this.updateTitle(title);
}
}
updateTitle(title) {
const titleElement = this.shadowRoot.querySelector('.card-title');
if (titleElement) {
titleElement.textContent = title || '';
}
}
}
// Template-based Form Field Component
class TemplateFormField extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.state = {
value: '',
error: '',
valid: true,
};
}
connectedCallback() {
this.render();
this.setupAttributes();
this.setupEventListeners();
}
static get observedAttributes() {
return ['label', 'type', 'placeholder', 'required', 'help-text'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this.handleAttributeChange(name, newValue);
}
}
render() {
const template = TemplateManager.cloneTemplate('form-field');
if (template) {
this.shadowRoot.appendChild(template);
this.input = this.shadowRoot.querySelector('.form-input');
this.label = this.shadowRoot.querySelector('.form-label');
this.error = this.shadowRoot.querySelector('.form-error');
this.help = this.shadowRoot.querySelector('.form-help');
}
}
setupAttributes() {
const label = this.getAttribute('label');
const type = this.getAttribute('type') || 'text';
const placeholder = this.getAttribute('placeholder');
const helpText = this.getAttribute('help-text');
if (this.label && label) {
this.label.textContent = label;
}
if (this.input) {
this.input.type = type;
if (placeholder) {
this.input.placeholder = placeholder;
}
}
if (this.help && helpText) {
this.help.textContent = helpText;
}
}
setupEventListeners() {
if (this.input) {
this.input.addEventListener('input', (e) => {
this.state.value = e.target.value;
this.validate();
this.dispatchEvent(
new CustomEvent('input', {
detail: { value: this.state.value },
bubbles: true,
})
);
});
this.input.addEventListener('blur', () => {
this.validate();
});
}
}
handleAttributeChange(name, value) {
switch (name) {
case 'label':
if (this.label) {
this.label.textContent = value || '';
}
break;
case 'type':
if (this.input) {
this.input.type = value || 'text';
}
break;
case 'placeholder':
if (this.input) {
this.input.placeholder = value || '';
}
break;
case 'help-text':
if (this.help) {
this.help.textContent = value || '';
}
break;
}
}
validate() {
const required = this.hasAttribute('required');
const value = this.state.value;
if (required && !value.trim()) {
this.setError('This field is required');
return false;
}
// Email validation
if (this.input.type === 'email' && value) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
this.setError('Please enter a valid email address');
return false;
}
}
this.clearError();
return true;
}
setError(message) {
this.state.error = message;
this.state.valid = false;
if (this.error) {
this.error.textContent = message;
this.error.style.display = 'block';
}
if (this.input) {
this.input.style.borderColor = '#ef4444';
}
}
clearError() {
this.state.error = '';
this.state.valid = true;
if (this.error) {
this.error.style.display = 'none';
}
if (this.input) {
this.input.style.borderColor = '#d1d5db';
}
}
// Public API
get value() {
return this.state.value;
}
set value(val) {
this.state.value = val;
if (this.input) {
this.input.value = val;
}
}
get valid() {
return this.state.valid;
}
focus() {
if (this.input) {
this.input.focus();
}
}
}
// Template-based Modal Component
class TemplateModal extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.state = {
open: false,
};
this.handleOverlayClick = this.handleOverlayClick.bind(this);
this.handleCloseClick = this.handleCloseClick.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
}
connectedCallback() {
this.render();
this.setupAttributes();
this.setupEventListeners();
}
disconnectedCallback() {
document.removeEventListener('keydown', this.handleKeyDown);
}
static get observedAttributes() {
return ['title', 'open'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this.handleAttributeChange(name, newValue);
}
}
render() {
const template = TemplateManager.cloneTemplate('modal');
if (template) {
this.shadowRoot.appendChild(template);
this.overlay = this.shadowRoot.querySelector('.modal-overlay');
this.title = this.shadowRoot.querySelector('.modal-title');
this.closeButton = this.shadowRoot.querySelector('.modal-close');
}
}
setupAttributes() {
const title = this.getAttribute('title');
const open = this.hasAttribute('open');
if (this.title && title) {
this.title.textContent = title;
}
if (open) {
this.open();
}
}
setupEventListeners() {
if (this.overlay) {
this.overlay.addEventListener('click', this.handleOverlayClick);
}
if (this.closeButton) {
this.closeButton.addEventListener('click', this.handleCloseClick);
}
document.addEventListener('keydown', this.handleKeyDown);
}
handleAttributeChange(name, value) {
switch (name) {
case 'title':
if (this.title) {
this.title.textContent = value || '';
}
break;
case 'open':
if (value !== null) {
this.open();
} else {
this.close();
}
break;
}
}
handleOverlayClick(event) {
if (event.target === this.overlay) {
this.close();
}
}
handleCloseClick() {
this.close();
}
handleKeyDown(event) {
if (event.key === 'Escape' && this.state.open) {
this.close();
}
}
open() {
this.state.open = true;
if (this.overlay) {
this.overlay.classList.add('open');
}
this.dispatchEvent(
new CustomEvent('modal-open', {
bubbles: true,
})
);
}
close() {
this.state.open = false;
if (this.overlay) {
this.overlay.classList.remove('open');
}
this.removeAttribute('open');
this.dispatchEvent(
new CustomEvent('modal-close', {
bubbles: true,
})
);
}
}
// Register template-based components
customElements.define('template-card', TemplateCard);
customElements.define('template-form-field', TemplateFormField);
customElements.define('template-modal', TemplateModal);
// Usage examples
document.body.innerHTML += `
<template-card title="Template-based Card">
<p>This card uses a reusable HTML template.</p>
<div slot="footer">Template system example</div>
</template-card>
<template-form-field
label="Email Address"
type="email"
placeholder="Enter your email"
help-text="We'll never share your email"
required>
</template-form-field>
<button onclick="document.querySelector('template-modal').setAttribute('open', '')">
Open Modal
</button>
<template-modal title="Example Modal">
<p>This modal is built using HTML templates.</p>
<div slot="footer">
<button onclick="document.querySelector('template-modal').close()">Cancel</button>
<button onclick="document.querySelector('template-modal').close()">Confirm</button>
</div>
</template-modal>
`;
Component Communication
Event System
// Event-driven Component Communication
class EventBus {
constructor() {
this.events = new Map();
}
// Subscribe to an event
on(event, callback, options = {}) {
if (!this.events.has(event)) {
this.events.set(event, []);
}
const subscription = {
callback,
once: options.once || false,
id: Date.now() + Math.random(),
};
this.events.get(event).push(subscription);
// Return unsubscribe function
return () => this.off(event, subscription.id);
}
// Subscribe to an event once
once(event, callback) {
return this.on(event, callback, { once: true });
}
// Unsubscribe from an event
off(event, subscriptionId) {
if (!this.events.has(event)) return;
const subscriptions = this.events.get(event);
const index = subscriptions.findIndex((sub) => sub.id === subscriptionId);
if (index > -1) {
subscriptions.splice(index, 1);
}
if (subscriptions.length === 0) {
this.events.delete(event);
}
}
// Emit an event
emit(event, data = null) {
if (!this.events.has(event)) return;
const subscriptions = [...this.events.get(event)];
subscriptions.forEach((subscription) => {
try {
subscription.callback(data);
if (subscription.once) {
this.off(event, subscription.id);
}
} catch (error) {
console.error(`Error in event handler for ${event}:`, error);
}
});
}
// Clear all subscriptions for an event
clear(event) {
if (event) {
this.events.delete(event);
} else {
this.events.clear();
}
}
}
// Global event bus instance
const globalEventBus = new EventBus();
// Parent-Child Communication Component
class ParentComponent extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.state = {
items: [],
selectedItem: null,
};
}
connectedCallback() {
this.render();
this.setupEventListeners();
this.loadInitialData();
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
padding: 20px;
font-family: system-ui, sans-serif;
}
.header {
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #e5e7eb;
}
.content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.selected-info {
background: #f3f4f6;
padding: 16px;
border-radius: 8px;
}
</style>
<div class="header">
<h2>Parent Component</h2>
<p>Selected: <span id="selected-display">None</span></p>
</div>
<div class="content">
<div>
<h3>Item List</h3>
<div id="item-list"></div>
</div>
<div class="selected-info">
<h3>Selected Item Details</h3>
<div id="item-details">No item selected</div>
</div>
</div>
`;
}
setupEventListeners() {
// Listen for child component events
this.addEventListener('item-selected', (event) => {
this.handleItemSelected(event.detail);
});
this.addEventListener('item-deleted', (event) => {
this.handleItemDeleted(event.detail);
});
// Listen for global events
globalEventBus.on('refresh-data', () => {
this.loadInitialData();
});
}
loadInitialData() {
this.state.items = [
{ id: 1, name: 'Item 1', description: 'First item' },
{ id: 2, name: 'Item 2', description: 'Second item' },
{ id: 3, name: 'Item 3', description: 'Third item' },
];
this.renderItemList();
}
renderItemList() {
const container = this.shadowRoot.getElementById('item-list');
if (!container) return;
container.innerHTML = '';
this.state.items.forEach((item) => {
const itemElement = document.createElement('child-item');
itemElement.setAttribute('item-id', item.id);
itemElement.setAttribute('item-name', item.name);
itemElement.setAttribute('item-description', item.description);
container.appendChild(itemElement);
});
}
handleItemSelected(itemData) {
this.state.selectedItem = itemData;
const selectedDisplay = this.shadowRoot.getElementById('selected-display');
const itemDetails = this.shadowRoot.getElementById('item-details');
if (selectedDisplay) {
selectedDisplay.textContent = itemData.name;
}
if (itemDetails) {
itemDetails.innerHTML = `
<p><strong>ID:</strong> ${itemData.id}</p>
<p><strong>Name:</strong> ${itemData.name}</p>
<p><strong>Description:</strong> ${itemData.description}</p>
`;
}
// Emit global event
globalEventBus.emit('item-selection-changed', itemData);
}
handleItemDeleted(itemData) {
this.state.items = this.state.items.filter(
(item) => item.id !== itemData.id
);
if (this.state.selectedItem && this.state.selectedItem.id === itemData.id) {
this.state.selectedItem = null;
this.handleItemSelected({ id: null, name: 'None', description: '' });
}
this.renderItemList();
// Emit global event
globalEventBus.emit('item-deleted', itemData);
}
}
// Child Component
class ChildItem extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.state = {
selected: false,
};
this.handleClick = this.handleClick.bind(this);
this.handleDelete = this.handleDelete.bind(this);
}
connectedCallback() {
this.render();
this.setupEventListeners();
}
static get observedAttributes() {
return ['item-id', 'item-name', 'item-description'];
}
render() {
const id = this.getAttribute('item-id');
const name = this.getAttribute('item-name');
const description = this.getAttribute('item-description');
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
margin-bottom: 8px;
}
.item {
padding: 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.item:hover {
background: #f9fafb;
border-color: #3b82f6;
}
.item.selected {
background: #dbeafe;
border-color: #3b82f6;
}
.item-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.item-name {
font-weight: 600;
margin: 0;
}
.item-description {
margin: 4px 0 0 0;
color: #6b7280;
font-size: 14px;
}
.delete-btn {
background: #ef4444;
color: white;
border: none;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.delete-btn:hover {
background: #dc2626;
}
</style>
<div class="item ${this.state.selected ? 'selected' : ''}">
<div class="item-header">
<h4 class="item-name">${name}</h4>
<button class="delete-btn">Delete</button>
</div>
<p class="item-description">${description}</p>
</div>
`;
}
setupEventListeners() {
const item = this.shadowRoot.querySelector('.item');
const deleteBtn = this.shadowRoot.querySelector('.delete-btn');
if (item) {
item.addEventListener('click', this.handleClick);
}
if (deleteBtn) {
deleteBtn.addEventListener('click', this.handleDelete);
}
// Listen for global selection changes
globalEventBus.on('item-selection-changed', (itemData) => {
const myId = parseInt(this.getAttribute('item-id'));
this.state.selected = itemData.id === myId;
this.updateSelectedState();
});
}
handleClick(event) {
event.stopPropagation();
const itemData = {
id: parseInt(this.getAttribute('item-id')),
name: this.getAttribute('item-name'),
description: this.getAttribute('item-description'),
};
// Dispatch event to parent
this.dispatchEvent(
new CustomEvent('item-selected', {
detail: itemData,
bubbles: true,
composed: true,
})
);
}
handleDelete(event) {
event.stopPropagation();
const itemData = {
id: parseInt(this.getAttribute('item-id')),
name: this.getAttribute('item-name'),
description: this.getAttribute('item-description'),
};
// Dispatch event to parent
this.dispatchEvent(
new CustomEvent('item-deleted', {
detail: itemData,
bubbles: true,
composed: true,
})
);
}
updateSelectedState() {
const item = this.shadowRoot.querySelector('.item');
if (item) {
item.classList.toggle('selected', this.state.selected);
}
}
}
// Register components
customElements.define('parent-component', ParentComponent);
customElements.define('child-item', ChildItem);
// Observer Component - listens to global events
class EventObserver extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.eventLog = [];
}
connectedCallback() {
this.render();
this.setupEventListeners();
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
padding: 20px;
background: #f9fafb;
border-radius: 8px;
margin-top: 20px;
}
.log-entry {
padding: 8px;
margin: 4px 0;
background: white;
border-left: 3px solid #3b82f6;
border-radius: 4px;
font-size: 12px;
}
.timestamp {
color: #6b7280;
}
</style>
<h3>Event Observer</h3>
<p>Global events will appear here:</p>
<div id="event-log"></div>
<button id="clear-log">Clear Log</button>
`;
const clearBtn = this.shadowRoot.getElementById('clear-log');
if (clearBtn) {
clearBtn.addEventListener('click', () => this.clearLog());
}
}
setupEventListeners() {
// Listen to various global events
globalEventBus.on('item-selection-changed', (data) => {
this.logEvent('Item Selected', data);
});
globalEventBus.on('item-deleted', (data) => {
this.logEvent('Item Deleted', data);
});
globalEventBus.on('refresh-data', () => {
this.logEvent('Data Refreshed', null);
});
}
logEvent(eventType, data) {
const timestamp = new Date().toLocaleTimeString();
const entry = {
type: eventType,
data,
timestamp,
};
this.eventLog.unshift(entry);
// Keep only last 10 entries
if (this.eventLog.length > 10) {
this.eventLog = this.eventLog.slice(0, 10);
}
this.renderLog();
}
renderLog() {
const logContainer = this.shadowRoot.getElementById('event-log');
if (!logContainer) return;
logContainer.innerHTML = this.eventLog
.map(
(entry) => `
<div class="log-entry">
<strong>${entry.type}</strong>
<span class="timestamp">(${entry.timestamp})</span>
${entry.data ? `<br>Data: ${JSON.stringify(entry.data)}` : ''}
</div>
`
)
.join('');
}
clearLog() {
this.eventLog = [];
this.renderLog();
}
}
customElements.define('event-observer', EventObserver);
// Usage example
document.body.innerHTML += `
<parent-component></parent-component>
<event-observer></event-observer>
<button onclick="globalEventBus.emit('refresh-data')">
Refresh Data (Global Event)
</button>
`;
Component Lifecycle and State Management
// Advanced Component with Complete Lifecycle Management
class AdvancedLifecycleComponent extends HTMLElement {
constructor() {
super();
// Initialize shadow DOM
this.attachShadow({ mode: 'open' });
// Component state
this.state = {
initialized: false,
connected: false,
data: null,
loading: false,
error: null,
subscriptions: [],
};
// Lifecycle hooks
this.onInit = this.onInit.bind(this);
this.onMount = this.onMount.bind(this);
this.onUpdate = this.onUpdate.bind(this);
this.onUnmount = this.onUnmount.bind(this);
// Initialize component
this.onInit();
}
// Custom lifecycle hooks
onInit() {
console.log('Component initialized');
this.state.initialized = true;
}
onMount() {
console.log('Component mounted');
this.loadData();
}
onUpdate(changes) {
console.log('Component updated', changes);
}
onUnmount() {
console.log('Component unmounted');
this.cleanup();
}
// Built-in lifecycle callbacks
connectedCallback() {
this.state.connected = true;
this.render();
this.setupEventListeners();
this.onMount();
}
disconnectedCallback() {
this.state.connected = false;
this.onUnmount();
}
static get observedAttributes() {
return ['data-source', 'auto-refresh', 'refresh-interval'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
const changes = { [name]: { oldValue, newValue } };
this.handleAttributeChange(name, newValue);
this.onUpdate(changes);
}
}
adoptedCallback() {
console.log('Component adopted to new document');
this.render();
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
padding: 20px;
border: 1px solid #e5e7eb;
border-radius: 8px;
font-family: system-ui, sans-serif;
}
.loading {
display: flex;
align-items: center;
gap: 8px;
color: #6b7280;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid #e5e7eb;
border-top: 2px solid #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.error {
padding: 12px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 4px;
color: #dc2626;
}
.data-display {
background: #f9fafb;
padding: 16px;
border-radius: 4px;
margin: 12px 0;
}
.status {
font-size: 12px;
color: #6b7280;
margin-top: 12px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
<h3>Advanced Lifecycle Component</h3>
<div id="content">
${this.renderContent()}
</div>
<div class="status">
<p>Initialized: ${this.state.initialized}</p>
<p>Connected: ${this.state.connected}</p>
<p>Data Source: ${this.getAttribute('data-source') || 'None'}</p>
<p>Auto Refresh: ${this.hasAttribute('auto-refresh')}</p>
</div>
<button id="refresh-btn">Refresh Data</button>
<button id="clear-btn">Clear Data</button>
`;
this.setupButtons();
}
renderContent() {
if (this.state.loading) {
return `
<div class="loading">
<div class="spinner"></div>
<span>Loading data...</span>
</div>
`;
}
if (this.state.error) {
return `
<div class="error">
<strong>Error:</strong> ${this.state.error}
</div>
`;
}
if (this.state.data) {
return `
<div class="data-display">
<h4>Data Loaded:</h4>
<pre>${JSON.stringify(this.state.data, null, 2)}</pre>
</div>
`;
}
return '<p>No data loaded</p>';
}
setupButtons() {
const refreshBtn = this.shadowRoot.getElementById('refresh-btn');
const clearBtn = this.shadowRoot.getElementById('clear-btn');
if (refreshBtn) {
refreshBtn.addEventListener('click', () => this.loadData());
}
if (clearBtn) {
clearBtn.addEventListener('click', () => this.clearData());
}
}
setupEventListeners() {
// Set up auto-refresh if enabled
if (this.hasAttribute('auto-refresh')) {
this.setupAutoRefresh();
}
}
setupAutoRefresh() {
const interval = parseInt(this.getAttribute('refresh-interval')) || 5000;
const intervalId = setInterval(() => {
if (this.state.connected) {
this.loadData();
}
}, interval);
// Store subscription for cleanup
this.state.subscriptions.push(() => {
clearInterval(intervalId);
});
}
async loadData() {
this.setState({ loading: true, error: null });
try {
const dataSource = this.getAttribute('data-source');
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
const data = {
source: dataSource || 'default',
timestamp: new Date().toISOString(),
items: Array.from({ length: 3 }, (_, i) => ({
id: i + 1,
name: `Item ${i + 1}`,
value: Math.random() * 100,
})),
};
this.setState({ data, loading: false });
// Dispatch custom event
this.dispatchEvent(
new CustomEvent('data-loaded', {
detail: data,
bubbles: true,
composed: true,
})
);
} catch (error) {
this.setState({
error: error.message,
loading: false,
});
}
}
clearData() {
this.setState({
data: null,
error: null,
});
}
setState(newState) {
const oldState = { ...this.state };
this.state = { ...this.state, ...newState };
// Re-render content area
const content = this.shadowRoot.getElementById('content');
if (content) {
content.innerHTML = this.renderContent();
}
// Dispatch state change event
this.dispatchEvent(
new CustomEvent('state-change', {
detail: { oldState, newState: this.state },
bubbles: true,
})
);
}
handleAttributeChange(name, value) {
switch (name) {
case 'data-source':
if (this.state.connected) {
this.loadData();
}
break;
case 'auto-refresh':
if (value !== null) {
this.setupAutoRefresh();
}
break;
case 'refresh-interval':
// Restart auto-refresh with new interval
this.cleanup();
if (this.hasAttribute('auto-refresh')) {
this.setupAutoRefresh();
}
break;
}
}
cleanup() {
// Clean up all subscriptions
this.state.subscriptions.forEach((unsubscribe) => {
try {
unsubscribe();
} catch (error) {
console.error('Error during cleanup:', error);
}
});
this.state.subscriptions = [];
}
// Public API
refresh() {
return this.loadData();
}
getState() {
return { ...this.state };
}
getData() {
return this.state.data;
}
}
// Register the component
customElements.define('advanced-lifecycle', AdvancedLifecycleComponent);
// Usage examples
document.body.innerHTML += `
<advanced-lifecycle
data-source="api/users"
auto-refresh
refresh-interval="3000">
</advanced-lifecycle>
<script>
// Example of interacting with the component
const component = document.querySelector('advanced-lifecycle');
component.addEventListener('data-loaded', (event) => {
console.log('Data loaded:', event.detail);
});
component.addEventListener('state-change', (event) => {
console.log('State changed:', event.detail);
});
// Change attributes programmatically
setTimeout(() => {
component.setAttribute('data-source', 'api/posts');
}, 10000);
</script>
`;
Conclusion
Web Components provide a powerful way to create reusable, encapsulated UI elements using standard web technologies. By leveraging Custom Elements, Shadow DOM, and HTML Templates, you can build component libraries that work across different frameworks and applications. The key benefits include true encapsulation, reusability, and framework independence, making Web Components an excellent choice for building design systems and shared component libraries.
When building Web Components, focus on creating clear APIs, handling lifecycle events properly, and implementing robust event communication patterns. This ensures your components are maintainable, performant, and easy to use across different contexts.