Skip to main content

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

  1. Naming Conventions

    • Always use a hyphen in custom element names
    • Use descriptive, semantic names
    • Prefix components with project/organization name
  2. Performance

    • Use template elements for repeated structures
    • Minimize shadow DOM operations
    • Lazy load components when possible
  3. Accessibility

    • Maintain ARIA attributes
    • Ensure keyboard navigation works
    • Preserve semantic meaning
  4. Maintenance

    • Document public API and attributes
    • Keep components focused and single-purpose
    • Follow the Single Responsibility Principle