Web Components
Introduction
Web Components are a set of web platform APIs that allow you to create reusable custom elements. They're built on three main technologies: Custom Elements, Shadow DOM, and HTML Templates.
1. Custom Elements
Theory
Custom Elements allow you to define your own HTML tags with custom functionality. They must have a hyphen in their name (e.g., 'my-element') to avoid conflicts with standard HTML elements.
Example
class MyElement extends HTMLElement {
constructor() {
super();
this.innerHTML = 'Hello from MyElement!';
}
// Lifecycle Methods
connectedCallback() {
// Called when element is added to document
console.log('Element added to page');
}
disconnectedCallback() {
// Called when element is removed from document
console.log('Element removed from page');
}
attributeChangedCallback(name, oldValue, newValue) {
// Called when an attribute is changed/added/removed
console.log(`Attribute ${name} changed from ${oldValue} to ${newValue}`);
}
static get observedAttributes() {
// Specify which attributes to watch for changes
return ['title', 'content'];
}
}
// Register the custom element
customElements.define('my-element', MyElement);
2. Shadow DOM
Theory
Shadow DOM provides encapsulation for DOM and CSS, creating a separate DOM tree that's isolated from the main document's DOM. This prevents style leakage and naming conflicts.
Example
class ShadowElement extends HTMLElement {
constructor() {
super();
// Create shadow root
const shadow = this.attachShadow({ mode: 'open' });
// Create element
const wrapper = document.createElement('div');
wrapper.textContent = 'This is in Shadow DOM';
// Add styles
const style = document.createElement('style');
style.textContent = `
div {
padding: 10px;
background: #f0f0f0;
border-radius: 5px;
}
`;
// Attach to shadow DOM
shadow.appendChild(style);
shadow.appendChild(wrapper);
}
}
customElements.define('shadow-element', ShadowElement);
3. HTML Templates
Theory
HTML Templates (<template>
tag) allow you to define reusable HTML that can be instantiated later. Content inside a template is not rendered until it's cloned and added to the document.
Example
// Define template in HTML
/*
<template id="my-template">
<style>
.container {
border: 1px solid #ccc;
padding: 15px;
}
</style>
<div class="container">
<h2><slot name="title">Default Title</slot></h2>
<div><slot>Default content</slot></div>
</div>
</template>
*/
class TemplateElement extends HTMLElement {
constructor() {
super();
// Get template content
const template = document.getElementById('my-template');
const templateContent = template.content;
// Create shadow root and clone template
const shadow = this.attachShadow({ mode: 'open' });
shadow.appendChild(templateContent.cloneNode(true));
}
}
customElements.define('template-element', TemplateElement);
Putting It All Together
Complete Example
class CompleteComponent extends HTMLElement {
constructor() {
super();
// Create shadow root
const shadow = this.attachShadow({ mode: 'open' });
// Create template
const template = document.createElement('template');
template.innerHTML = `
<style>
:host {
display: block;
padding: 20px;
}
.card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 15px;
}
h2 {
margin-top: 0;
color: #333;
}
</style>
<div class="card">
<h2><slot name="title">Default Title</slot></h2>
<div><slot>Default content</slot></div>
</div>
`;
// Clone and attach template
shadow.appendChild(template.content.cloneNode(true));
}
// Lifecycle and attribute handling
static get observedAttributes() {
return ['title'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'title') {
const title = this.shadowRoot.querySelector('h2');
if (title) title.textContent = newValue;
}
}
}
customElements.define('complete-component', CompleteComponent);
// Usage:
/*
<complete-component>
<span slot="title">My Custom Component</span>
<p>This is the content of my component</p>
</complete-component>
*/
Custom Box Element using Shadow DOM
class SquareBox extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: 'open' })
this.shadowRoot.innerHTML = `
<style>
#box {
height : 200px;
width: 200px;
border: 1px solid #000;
}
#inner-box {
backgroundColor : red;
}
p {
color: black;
font-size: 16px;
}
</style>
<div id='box'> <div id="inner-box"></div></div>
`
}
static get observedAttributes() {
return ['background']
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'background') {
this.shadowRoot.querySelector('#box').style.backgroundColor = newValue;
}
}
}
customElements.define('square-box', SquareBox)
/*
<square-box background="maroon"></square-box>
<square-box background="blue"></square-box>
*/
const boxes = Array.from({ length: 10 }, () => {
const box = document.createElement('square-box')
box.setAttribute('background', 'red')
return box
})
const container = document.getElementById('container')
boxes.forEach(box => container.appendChild(box))
// 10 box created with red background
Custom Star Rating Component
class StarRating extends HTMLElement {
static get observedAttributes() {
return ['value', 'disabled'];
}
#value = 0;
#disabled = false;
constructor() {
super();
this.attachShadow({ mode: 'open' });
// Initialize state
this.#value = 0;
this.#disabled = false;
// Render initial template
this.render();
}
// Getters and setters
get value() {
return this.#value;
}
set value(newValue) {
const value = Math.min(Math.max(0, Number(newValue)), 5);
if (this.#value !== value) {
this.#value = value;
this.updateStars();
}
}
get disabled() {
return this.#disabled;
}
set disabled(value) {
this.#disabled = Boolean(value);
this.shadowRoot.querySelector('.stars').style.cursor =
this.#disabled ? 'default' : 'pointer';
}
// Lifecycle callbacks
connectedCallback() {
this.setupEventListeners();
// Set initial value if provided as attribute
if (this.hasAttribute('value')) {
this.value = this.getAttribute('value');
}
if (this.hasAttribute('disabled')) {
this.disabled = true;
}
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) return;
switch (name) {
case 'value':
this.value = newValue;
break;
case 'disabled':
this.disabled = newValue !== null;
break;
}
}
// Private methods
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: inline-block;
}
.stars {
display: inline-flex;
gap: 4px;
cursor: pointer;
user-select: none;
}
.star {
font-size: 24px;
color: #d4d4d4;
transition: color 0.2s ease-in-out;
}
:host(:not([disabled])) .star:hover,
.star.selected {
color: #ffd700;
}
:host(:not([disabled])) .star:hover ~ .star {
color: #d4d4d4 !important;
}
:host([disabled]) .stars {
cursor: default;
opacity: 0.6;
}
</style>
<div class="stars" role="radiogroup" aria-label="Rating">
${Array.from({ length: 5 }, (_, i) => `
<span class="star"
role="radio"
aria-checked="false"
tabindex="${i === 0 ? 0 : -1}"
data-value="${i + 1}">
★
</span>
`).join('')}
</div>
`;
}
setupEventListeners() {
const starsContainer = this.shadowRoot.querySelector('.stars');
// Event delegation for better performance
starsContainer.addEventListener('click', (e) => {
if (this.disabled) return;
const star = e.target.closest('.star');
if (star) {
this.selectRating(Number(star.dataset.value));
}
});
starsContainer.addEventListener('mouseover', (e) => {
if (this.disabled) return;
const star = e.target.closest('.star');
if (star) {
this.previewRating(Number(star.dataset.value));
}
});
starsContainer.addEventListener('mouseleave', () => {
if (this.disabled) return;
this.updateStars();
});
// Keyboard navigation
starsContainer.addEventListener('keydown', (e) => {
if (this.disabled) return;
const target = e.target.closest('.star');
if (!target) return;
const currentValue = Number(target.dataset.value);
switch (e.key) {
case 'ArrowRight':
case 'ArrowUp':
e.preventDefault();
this.selectRating(Math.min(currentValue + 1, 5));
this.focusStar(currentValue < 5 ? currentValue : 4);
break;
case 'ArrowLeft':
case 'ArrowDown':
e.preventDefault();
this.selectRating(Math.max(currentValue - 1, 1));
this.focusStar(currentValue > 1 ? currentValue - 2 : 0);
break;
case ' ':
case 'Enter':
e.preventDefault();
this.selectRating(currentValue);
break;
}
});
}
focusStar(index) {
const stars = this.shadowRoot.querySelectorAll('.star');
stars.forEach(star => star.setAttribute('tabindex', '-1'));
stars[index].setAttribute('tabindex', '0');
stars[index].focus();
}
selectRating(value) {
if (this.disabled || this.value === value) return;
this.value = value;
// Dispatch custom event
this.dispatchEvent(new CustomEvent('rating-change', {
bubbles: true,
composed: true,
detail: { value: this.value }
}));
}
previewRating(value) {
if (this.disabled) return;
const stars = this.shadowRoot.querySelectorAll('.star');
stars.forEach((star, index) => {
star.classList.toggle('selected', index < value);
star.setAttribute('aria-checked', index < value ? 'true' : 'false');
});
}
updateStars() {
const stars = this.shadowRoot.querySelectorAll('.star');
stars.forEach((star, index) => {
star.classList.toggle('selected', index < this.value);
star.setAttribute('aria-checked', index < this.value ? 'true' : 'false');
});
}
}
// Register the custom element
customElements.define('star-rating', StarRating);
// Usage example:
document.querySelector('star-rating').addEventListener('rating-change', (event) => {
console.log('New rating:', event.detail.value);
});
/*
<star-rating value="3"></star-rating>
<star-rating value="5"></star-rating>
<star-rating value="5" disabled></star-rating>
*/
Best Practices
-
Naming Conventions
- Always use a hyphen in custom element names
- Use descriptive, semantic names
- Prefix components with project/organization name
-
Performance
- Use template elements for repeated structures
- Minimize shadow DOM operations
- Lazy load components when possible
-
Accessibility
- Maintain ARIA attributes
- Ensure keyboard navigation works
- Preserve semantic meaning
-
Maintenance
- Document public API and attributes
- Keep components focused and single-purpose
- Follow the Single Responsibility Principle