What are Web Components?
Web Components are a set of web platform APIs that allow you to create reusable, encapsulated HTML elements. They consist of three main technologies:
- Custom Elements - Define new HTML elements
- Shadow DOM - Encapsulated DOM and styling
- HTML Templates - Reusable markup patterns
HTML Templates
The <template> element holds HTML that isn't rendered immediately but can be instantiated later with JavaScript.
Basic Template Usage
html
<template id="user-card-template">
<style>
.user-card {
border: 2px solid #3498db;
border-radius: 8px;
padding: 16px;
margin: 8px;
background: #f8f9fa;
max-width: 300px;
}
.avatar {
width: 60px;
height: 60px;
border-radius: 50%;
object-fit: cover;
margin-bottom: 12px;
}
.name {
font-size: 1.2em;
font-weight: bold;
color: #2c3e50;
margin: 0 0 8px 0;
}
.email {
color: #7f8c8d;
margin: 0;
}
</style>
<div class="user-card">
<img class="avatar" src="" alt="User Avatar">
<h3 class="name"></h3>
<p class="email"></p>
</div>
</template>
<div id="user-container"></div>
<script>
function createUserCard(userData) {
const template = document.getElementById('user-card-template');
const clone = template.content.cloneNode(true);
clone.querySelector('.avatar').src = userData.avatar;
clone.querySelector('.name').textContent = userData.name;
clone.querySelector('.email').textContent = userData.email;
return clone;
}
// Usage
const users = [
{ name: 'John Doe', email: 'john@example.com', avatar: 'https://via.placeholder.com/60' },
{ name: 'Jane Smith', email: 'jane@example.com', avatar: 'https://via.placeholder.com/60' }
];
const container = document.getElementById('user-container');
users.forEach(user => {
const userCard = createUserCard(user);
container.appendChild(userCard);
});
</script>
Custom Elements
Custom Elements allow you to define new HTML tags with custom behavior.
Autonomous Custom Elements
html
<script>
class ToggleButton extends HTMLElement {
constructor() {
super();
this.isToggled = false;
this.render();
this.addEventListeners();
}
// Observed attributes - triggers attributeChangedCallback
static get observedAttributes() {
return ['label', 'color'];
}
render() {
const label = this.getAttribute('label') || 'Toggle';
const color = this.getAttribute('color') || '#3498db';
this.innerHTML = `
<style>
.toggle-btn {
padding: 12px 24px;
border: none;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
transition: all 0.3s ease;
background: ${color};
color: white;
}
.toggle-btn:hover {
opacity: 0.8;
transform: translateY(-2px);
}
.toggle-btn.toggled {
background: #e74c3c;
transform: scale(0.95);
}
</style>
<button class="toggle-btn">${label}</button>
`;
}
addEventListeners() {
const button = this.querySelector('.toggle-btn');
button.addEventListener('click', () => {
this.toggle();
});
}
toggle() {
this.isToggled = !this.isToggled;
const button = this.querySelector('.toggle-btn');
button.classList.toggle('toggled', this.isToggled);
// Dispatch custom event
this.dispatchEvent(new CustomEvent('toggle', {
detail: { toggled: this.isToggled },
bubbles: true
}));
}
// Called when observed attributes change
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this.render();
this.addEventListeners();
}
}
}
// Define the custom element
customElements.define('toggle-button', ToggleButton);
</script>
<!-- Usage -->
<toggle-button label="Click Me" color="#2ecc71"></toggle-button>
<toggle-button label="Another Toggle" color="#9b59b6"></toggle-button>
<script>
// Listen for custom events
document.addEventListener('toggle', (e) => {
console.log('Toggle state:', e.detail.toggled);
});
</script>
Shadow DOM
Shadow DOM provides encapsulation, preventing styles and scripts from leaking in or out.
Basic Shadow DOM Usage
html
<script>
class ShadowCard extends HTMLElement {
constructor() {
super();
// Create shadow root
this.attachShadow({ mode: 'open' });
// Create template
const template = document.createElement('template');
template.innerHTML = `
<style>
:host {
display: block;
margin: 16px 0;
}
.card {
background: white;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
padding: 20px;
transition: transform 0.2s ease;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 12px rgba(0, 0, 0, 0.15);
}
.title {
font-size: 1.5em;
color: #2c3e50;
margin: 0 0 12px 0;
border-bottom: 2px solid #3498db;
padding-bottom: 8px;
}
::slotted(p) {
color: #555;
line-height: 1.6;
}
::slotted(.highlight) {
background: #fff3cd;
padding: 4px 8px;
border-radius: 4px;
}
</style>
<div class="card">
<h2 class="title">
<slot name="title">Default Title</slot>
</h2>
<div class="content">
<slot></slot>
</div>
</div>
`;
// Clone template and append to shadow root
this.shadowRoot.appendChild(template.content.cloneNode(true));
}
}
customElements.define('shadow-card', ShadowCard);
</script>
<!-- Usage -->
<shadow-card>
<span slot="title">My Custom Card</span>
<p>This is the main content of the card.</p>
<p class="highlight">This paragraph has special styling!</p>
<button onclick="alert('Button works!')">Click me</button>
</shadow-card>
Advanced Custom Element with Lifecycle Methods
html
<script>
class CounterComponent extends HTMLElement {
constructor() {
super();
this.count = 0;
this.attachShadow({ mode: 'open' });
this.render();
}
static get observedAttributes() {
return ['initial-count', 'step', 'max', 'min'];
}
connectedCallback() {
console.log('Counter component added to page');
this.setupEventListeners();
// Initialize count from attribute
const initialCount = this.getAttribute('initial-count');
if (initialCount) {
this.count = parseInt(initialCount, 10);
this.updateDisplay();
}
}
disconnectedCallback() {
console.log('Counter component removed from page');
this.cleanup();
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'initial-count' && oldValue !== newValue) {
this.count = parseInt(newValue, 10) || 0;
this.updateDisplay();
}
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: inline-block;
border: 2px solid #3498db;
border-radius: 8px;
padding: 16px;
background: #f8f9fa;
user-select: none;
}
.counter {
text-align: center;
}
.display {
font-size: 2em;
font-weight: bold;
color: #2c3e50;
margin: 16px 0;
padding: 8px;
background: white;
border-radius: 4px;
}
.controls {
display: flex;
gap: 8px;
justify-content: center;
}
button {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.2s ease;
}
.decrement {
background: #e74c3c;
color: white;
}
.increment {
background: #2ecc71;
color: white;
}
.reset {
background: #95a5a6;
color: white;
}
button:hover {
opacity: 0.8;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
<div class="counter">
<div class="display">${this.count}</div>
<div class="controls">
<button class="decrement">-</button>
<button class="reset">Reset</button>
<button class="increment">+</button>
</div>
</div>
`;
}
setupEventListeners() {
const decrementBtn = this.shadowRoot.querySelector('.decrement');
const incrementBtn = this.shadowRoot.querySelector('.increment');
const resetBtn = this.shadowRoot.querySelector('.reset');
decrementBtn.addEventListener('click', () => this.decrement());
incrementBtn.addEventListener('click', () => this.increment());
resetBtn.addEventListener('click', () => this.reset());
}
increment() {
const step = parseInt(this.getAttribute('step'), 10) || 1;
const max = parseInt(this.getAttribute('max'), 10);
if (max === undefined || this.count + step <= max) {
this.count += step;
this.updateDisplay();
this.dispatchChangeEvent();
}
}
decrement() {
const step = parseInt(this.getAttribute('step'), 10) || 1;
const min = parseInt(this.getAttribute('min'), 10);
if (min === undefined || this.count - step >= min) {
this.count -= step;
this.updateDisplay();
this.dispatchChangeEvent();
}
}
reset() {
const initialCount = parseInt(this.getAttribute('initial-count'), 10) || 0;
this.count = initialCount;
this.updateDisplay();
this.dispatchChangeEvent();
}
updateDisplay() {
const display = this.shadowRoot.querySelector('.display');
display.textContent = this.count;
// Update button states
const min = parseInt(this.getAttribute('min'), 10);
const max = parseInt(this.getAttribute('max'), 10);
const decrementBtn = this.shadowRoot.querySelector('.decrement');
const incrementBtn = this.shadowRoot.querySelector('.increment');
decrementBtn.disabled = min !== undefined && this.count <= min;
incrementBtn.disabled = max !== undefined && this.count >= max;
}
dispatchChangeEvent() {
this.dispatchEvent(new CustomEvent('count-changed', {
detail: { count: this.count },
bubbles: true
}));
}
cleanup() {
// Clean up any intervals, event listeners, etc.
}
// Public API
getValue() {
return this.count;
}
setValue(value) {
this.count = parseInt(value, 10) || 0;
this.updateDisplay();
}
}
customElements.define('counter-component', CounterComponent);
</script>
<!-- Usage Examples -->
<counter-component initial-count="5" step="2" min="0" max="20"></counter-component>
<counter-component initial-count="10" step="5"></counter-component>
<script>
document.addEventListener('count-changed', (e) => {
console.log('Counter value:', e.detail.count);
});
</script>
Customized Built-in Elements
html
<script>
class FancyButton extends HTMLButtonElement {
constructor() {
super();
this.addRippleEffect();
}
addRippleEffect() {
this.style.position = 'relative';
this.style.overflow = 'hidden';
this.style.padding = '12px 24px';
this.style.border = 'none';
this.style.borderRadius = '6px';
this.style.background = '#3498db';
this.style.color = 'white';
this.style.cursor = 'pointer';
this.style.transition = 'all 0.3s ease';
this.addEventListener('click', (e) => {
const ripple = document.createElement('span');
const rect = this.getBoundingClientRect();
const size = Math.max(rect.width, rect.height);
const x = e.clientX - rect.left - size / 2;
const y = e.clientY - rect.top - size / 2;
ripple.style.cssText = `
position: absolute;
width: ${size}px;
height: ${size}px;
left: ${x}px;
top: ${y}px;
background: rgba(255, 255, 255, 0.5);
border-radius: 50%;
transform: scale(0);
animation: ripple 0.6s linear;
pointer-events: none;
`;
this.appendChild(ripple);
setTimeout(() => ripple.remove(), 600);
});
// Add ripple animation
const style = document.createElement('style');
style.textContent = `
@keyframes ripple {
to {
transform: scale(4);
opacity: 0;
}
}
`;
document.head.appendChild(style);
}
}
// Define customized built-in element
customElements.define('fancy-button', FancyButton, { extends: 'button' });
</script>
<!-- Usage -->
<button is="fancy-button">Click for Ripple Effect</button>
Slot-based Content Distribution
html
<script>
class TabContainer extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.activeTab = 0;
this.render();
}
connectedCallback() {
this.setupTabs();
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
}
.tab-headers {
display: flex;
background: #f8f9fa;
border-bottom: 1px solid #ddd;
}
.tab-content {
padding: 20px;
min-height: 200px;
}
::slotted(tab-header) {
padding: 12px 20px;
cursor: pointer;
border-right: 1px solid #ddd;
transition: background 0.2s ease;
}
::slotted(tab-header:hover) {
background: #e9ecef;
}
::slotted(tab-header.active) {
background: #007bff;
color: white;
}
::slotted(tab-panel) {
display: none;
}
::slotted(tab-panel.active) {
display: block;
}
</style>
<div class="tab-headers">
<slot name="headers"></slot>
</div>
<div class="tab-content">
<slot name="panels"></slot>
</div>
`;
}
setupTabs() {
const headers = this.querySelectorAll('tab-header');
const panels = this.querySelectorAll('tab-panel');
headers.forEach((header, index) => {
header.addEventListener('click', () => {
this.switchTab(index);
});
});
// Activate first tab
this.switchTab(0);
}
switchTab(index) {
const headers = this.querySelectorAll('tab-header');
const panels = this.querySelectorAll('tab-panel');
// Remove active class from all
headers.forEach(h => h.classList.remove('active'));
panels.forEach(p => p.classList.remove('active'));
// Add active class to selected
if (headers[index] && panels[index]) {
headers[index].classList.add('active');
panels[index].classList.add('active');
this.activeTab = index;
}
}
}
class TabHeader extends HTMLElement {
constructor() {
super();
this.slot = 'headers';
}
}
class TabPanel extends HTMLElement {
constructor() {
super();
this.slot = 'panels';
}
}
customElements.define('tab-container', TabContainer);
customElements.define('tab-header', TabHeader);
customElements.define('tab-panel', TabPanel);
</script>
<!-- Usage -->
<tab-container>
<tab-header>Tab 1</tab-header>
<tab-header>Tab 2</tab-header>
<tab-header>Tab 3</tab-header>
<tab-panel>
<h3>Content for Tab 1</h3>
<p>This is the first tab's content with some text and elements.</p>
<button>Button in Tab 1</button>
</tab-panel>
<tab-panel>
<h3>Content for Tab 2</h3>
<p>Second tab with different content.</p>
<ul>
<li>List item 1</li>
<li>List item 2</li>
</ul>
</tab-panel>
<tab-panel>
<h3>Content for Tab 3</h3>
<p>Third tab content here.</p>
<img src="https://via.placeholder.com/200x100" alt="Sample image">
</tab-panel>
</tab-container>
Web Components Best Practices
1. Performance Optimization
html
<script>
class LazyComponent extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.rendered = false;
}
connectedCallback() {
// Use Intersection Observer for lazy loading
this.observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && !this.rendered) {
this.render();
this.rendered = true;
this.observer.disconnect();
}
});
});
this.observer.observe(this);
}
disconnectedCallback() {
if (this.observer) {
this.observer.disconnect();
}
}
render() {
this.shadowRoot.innerHTML = `
<style>
.content {
padding: 20px;
background: #f0f0f0;
border-radius: 8px;
animation: fadeIn 0.5s ease-in;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
</style>
<div class="content">
<h3>Lazy Loaded Content</h3>
<p>This component only renders when it comes into view!</p>
</div>
`;
}
}
customElements.define('lazy-component', LazyComponent);
</script>
2. Error Handling
html
<script>
class RobustComponent extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.errorBoundary();
}
errorBoundary() {
try {
this.render();
} catch (error) {
console.error('Component render error:', error);
this.renderError();
}
}
render() {
// Potentially error-prone code
const data = JSON.parse(this.getAttribute('data') || '{}');
this.shadowRoot.innerHTML = `
<style>
.success { color: green; }
.error { color: red; padding: 10px; background: #ffe6e6; }
</style>
<div class="success">
<h3>${data.title || 'Default Title'}</h3>
<p>${data.description || 'Default description'}</p>
</div>
`;
}
renderError() {
this.shadowRoot.innerHTML = `
<style>
.error {
color: red;
padding: 10px;
background: #ffe6e6;
border-radius: 4px;
}
</style>
<div class="error">
⚠️ Component failed to render. Please check the data attribute.
</div>
`;
}
}
customElements.define('robust-component', RobustComponent);
</script>
<!-- Usage -->
<robust-component data='{"title": "Working Component", "description": "This works fine"}'></robust-component>
<robust-component data='invalid json'></robust-component>
3. Form Integration
html
<script>
class CustomInput extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.render();
}
static get formAssociated() { return true; }
connectedCallback() {
this.internals = this.attachInternals();
this.setupFormIntegration();
}
render() {
this.shadowRoot.innerHTML = `
<style>
.input-wrapper {
display: flex;
flex-direction: column;
gap: 4px;
}
label {
font-weight: bold;
color: #333;
}
input {
padding: 8px 12px;
border: 2px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
input:focus {
outline: none;
border-color: #007bff;
}
.error {
color: red;
font-size: 12px;
}
</style>
<div class="input-wrapper">
<label for="input">${this.getAttribute('label') || 'Input'}</label>
<input type="text" id="input"
placeholder="${this.getAttribute('placeholder') || ''}"
value="${this.getAttribute('value') || ''}">
<div class="error"></div>
</div>
`;
}
setupFormIntegration() {
const input = this.shadowRoot.querySelector('input');
const errorDiv = this.shadowRoot.querySelector('.error');
input.addEventListener('input', (e) => {
this.internals.setFormValue(e.target.value);
this.validate(e.target.value);
});
input.addEventListener('blur', () => {
this.validate(input.value);
});
}
validate(value) {
const errorDiv = this.shadowRoot.querySelector('.error');
const required = this.hasAttribute('required');
const minLength = this.getAttribute('min-length');
if (required && !value.trim()) {
this.internals.setValidity({valueMissing: true}, 'This field is required');
errorDiv.textContent = 'This field is required';
return false;
}
if (minLength && value.length < parseInt(minLength)) {
this.internals.setValidity({tooShort: true}, `Minimum ${minLength} characters required`);
errorDiv.textContent = `Minimum ${minLength} characters required`;
return false;
}
this.internals.setValidity({});
errorDiv.textContent = '';
return true;
}
// Form API methods
get value() {
return this.shadowRoot.querySelector('input').value;
}
set value(val) {
const input = this.shadowRoot.querySelector('input');
input.value = val;
this.internals.setFormValue(val);
}
get name() {
return this.getAttribute('name');
}
get form() {
return this.internals.form;
}
get validity() {
return this.internals.validity;
}
get validationMessage() {
return this.internals.validationMessage;
}
checkValidity() {
return this.internals.checkValidity();
}
reportValidity() {
return this.internals.reportValidity();
}
}
customElements.define('custom-input', CustomInput);
</script>
<!-- Usage in forms -->
<form id="test-form">
<custom-input name="username" label="Username" required min-length="3"></custom-input>
<custom-input name="email" label="Email" placeholder="Enter your email" required></custom-input>
<button type="submit">Submit</button>
</form>
<script>
document.getElementById('test-form').addEventListener('submit', (e) => {
e.preventDefault();
const formData = new FormData(e.target);
console.log('Form data:', Object.fromEntries(formData));
});
</script>
Testing Web Components
html
<script>
// Simple testing framework for components
class ComponentTester {
constructor(componentName) {
this.componentName = componentName;
this.tests = [];
}
test(name, testFn) {
this.tests.push({ name, testFn });
return this;
}
async run() {
console.log(`Running tests for ${this.componentName}:`);
for (const test of this.tests) {
try {
await test.testFn();
console.log(`✓ ${test.name}`);
} catch (error) {
console.error(`✗ ${test.name}:`, error.message);
}
}
}
}
// Example tests for counter component
const counterTests = new ComponentTester('counter-component')
.test('should render with initial count', () => {
const counter = document.createElement('counter-component');
counter.setAttribute('initial-count', '5');
document.body.appendChild(counter);
const display = counter.shadowRoot.querySelector('.display');
if (display.textContent !== '5') {
throw new Error(`Expected '5', got '${display.textContent}'`);
}
document.body.removeChild(counter);
})
.test('should increment count', () => {
const counter = document.createElement('counter-component');
document.body.appendChild(counter);
const incrementBtn = counter.shadowRoot.querySelector('.increment');
incrementBtn.click();
const display = counter.shadowRoot.querySelector('.display');
if (display.textContent !== '1') {
throw new Error(`Expected '1', got '${display.textContent}'`);
}
document.body.removeChild(counter);
})
.test('should respect max attribute', () => {
const counter = document.createElement('counter-component');
counter.setAttribute('initial-count', '5');
counter.setAttribute('max', '5');
document.body.appendChild(counter);
const incrementBtn = counter.shadowRoot.querySelector('.increment');
incrementBtn.click();
const display = counter.shadowRoot.querySelector('.display');
if (display.textContent !== '5') {
throw new Error(`Expected '5' (max reached), got '${display.textContent}'`);
}
document.body.removeChild(counter);
});
// Run tests when page loads
document.addEventListener('DOMContentLoaded', () => {
counterTests.run();
});
</script>