Web Components let you create reusable, encapsulated custom HTML elements. This tutorial covers Custom Elements, Shadow DOM, and templates—the foundation of modern component-based web development.
What you’ll learn:
What Web Components are and why they matter
Defining custom elements with CustomElementRegistry
Lifecycle callbacks (connectedCallback, disconnectedCallback, etc.)
Attributes versus properties
Shadow DOM for encapsulation
HTML templates and slots
Practical custom element examples
When to use custom elements
Prerequisites:
Comfortable with HTML, CSS, and JavaScript
Understanding of ES6 classes and modules
Web Components are a set of web platform APIs that let you create new HTML elements with encapsulation and reusability. They consist of:
Custom Elements — Define new HTML tags
Shadow DOM — Encapsulate styles and markup
HTML Templates — Reusable markup blocks
ES Modules — Organize and share components
Benefits:
Framework-agnostic — Work with any framework or vanilla JS
Reusable — Define once, use anywhere
Encapsulated — Styles and markup isolated from the rest of the page
Native — Part of the web platform standard
Performant — No framework overhead
Web Components work in all modern browsers (Chrome 67+, Firefox 63+, Safari 10.1+, Edge 79+). Polyfills exist for older browsers.
A custom element is a JavaScript class that extends HTMLElement.
Interactive code playground requires JavaScript. Here's the code:
<!-- HTML usage -->
<hello-world></hello-world>
<script>
// Define the custom element
class HelloWorld extends HTMLElement {
constructor() {
super();
this.textContent = 'Hello, World!';
}
}
// Register it
customElements.define('hello-world', HelloWorld);
</script>
Interactive code playground requires JavaScript. Here's the code:
<!-- HTML with attributes -->
<greeting-message name="Alice" emotion="excited"></greeting-message>
<script>
class GreetingMessage extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
const name = this.getAttribute('name') || 'Guest';
const emotion = this.getAttribute('emotion') || 'neutral';
this.textContent = `Hello, ${name}! I'm ${emotion}.`;
}
}
customElements.define('greeting-message', GreetingMessage);
</script>
Interactive code playground requires JavaScript. Here's the code:
<!-- Autonomous custom element (new element) -->
<my-button>Click me</my-button>
<!-- Customized built-in (extends existing element) -->
<button is="fancy-button">Fancy Click</button>
<script>
// Autonomous
class MyButton extends HTMLElement {
connectedCallback() {
this.style.padding = '10px';
this.addEventListener('click', () => alert('Clicked!'));
}
}
customElements.define('my-button', MyButton);
// Customized built-in
class FancyButton extends HTMLButtonElement {
connectedCallback() {
this.style.background = 'linear-gradient(45deg, #ff0066, #ff9933)';
this.style.color = 'white';
}
}
customElements.define('fancy-button', FancyButton, { extends: 'button' });
</script>
Customized Built-ins Limitation
Customized built-in elements (is="" attribute) have limited browser support in Safari. For better compatibility, use autonomous custom elements.
Custom elements have hooks that fire at specific lifecycle points.
Fires when the element is inserted into the DOM.
Interactive code playground requires JavaScript. Here's the code:
<log-message></log-message>
<script>
class LogMessage extends HTMLElement {
connectedCallback() {
console.log('Element added to DOM');
this.textContent = 'Element is now in the DOM!';
this.style.padding = '20px';
this.style.background = '#e3f2fd';
this.style.borderRadius = '4px';
}
}
customElements.define('log-message', LogMessage);
</script>
Fires when the element is removed from the DOM.
Interactive code playground requires JavaScript. Here's the code:
<timer-element></timer-element>
<script>
class TimerElement extends HTMLElement {
connectedCallback() {
this.interval = setInterval(() => {
this.count = (this.count || 0) + 1;
this.textContent = `Running for ${this.count} seconds`;
}, 1000);
}
disconnectedCallback() {
// Clean up when removed
clearInterval(this.interval);
console.log('Timer cleaned up');
}
}
customElements.define('timer-element', TimerElement);
// Simulate removal
setTimeout(() => {
document.querySelector('timer-element').remove();
}, 5000);
</script>
Fires when observed attributes change.
Interactive code playground requires JavaScript. Here's the code:
<!-- Change the status attribute -->
<status-badge status="pending"></status-badge>
<script>
class StatusBadge extends HTMLElement {
// Tell the element which attributes to observe
static get observedAttributes() {
return ['status'];
}
connectedCallback() {
this.update();
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'status') {
this.update();
}
}
update() {
const status = this.getAttribute('status') || 'unknown';
// Update appearance based on status
let bgColor = '#ccc';
if (status === 'pending') bgColor = '#fff3cd';
if (status === 'success') bgColor = '#d4edda';
if (status === 'error') bgColor = '#f8d7da';
this.style.padding = '8px 12px';
this.style.borderRadius = '4px';
this.style.backgroundColor = bgColor;
this.textContent = `Status: ${status}`;
}
}
customElements.define('status-badge', StatusBadge);
// Change the attribute
setTimeout(() => {
document.querySelector('status-badge').setAttribute('status', 'success');
}, 2000);
</script>
Fires when the element is adopted into a new document (rarely used).
Interactive code playground requires JavaScript. Here's the code:
<script>
class AdoptableElement extends HTMLElement {
adoptedCallback() {
console.log('Element moved to a new document');
}
}
customElements.define('adoptable-element', AdoptableElement);
</script>
Custom elements can have both HTML attributes and JavaScript properties. Handle both properly.
Interactive code playground requires JavaScript. Here's the code:
<!-- HTML attribute -->
<custom-input name="username" type="text"></custom-input>
<script>
class CustomInput extends HTMLElement {
connectedCallback() {
const wrapper = document.createElement('div');
wrapper.style.marginBottom = '10px';
const label = document.createElement('label');
label.textContent = this.getAttribute('name') || 'Input';
label.style.display = 'block';
label.style.marginBottom = '5px';
const input = document.createElement('input');
input.type = this.getAttribute('type') || 'text';
// Reflect attribute changes to property
input.addEventListener('change', () => {
this.value = input.value;
});
wrapper.appendChild(label);
wrapper.appendChild(input);
this.appendChild(wrapper);
}
// Property getter/setter
get value() {
return this.getAttribute('value') || '';
}
set value(val) {
this.setAttribute('value', val);
}
}
customElements.define('custom-input', CustomInput);
</script>
Interactive code playground requires JavaScript. Here's the code:
<!-- Use attributes for initial configuration -->
<data-table
rows="10"
sortable="true"
striped="true">
</data-table>
<script>
class DataTable extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
const rows = parseInt(this.getAttribute('rows')) || 5;
const sortable = this.hasAttribute('sortable');
const striped = this.hasAttribute('striped');
let html = '<table style="width: 100%; border-collapse: collapse;">';
for (let i = 0; i < rows; i++) {
const bgColor = striped && i % 2 === 1 ? '#f5f5f5' : 'white';
html += `<tr style="background-color: ${bgColor};">
<td style="padding: 8px; border: 1px solid #ddd;">Row ${i + 1}, Cell 1</td>
<td style="padding: 8px; border: 1px solid #ddd;">Row ${i + 1}, Cell 2</td>
</tr>`;
}
html += '</table>';
this.innerHTML = html;
}
static get observedAttributes() {
return ['rows', 'sortable', 'striped'];
}
attributeChangedCallback(name, oldValue, newValue) {
// Refresh table when attributes change
if (oldValue !== newValue) {
this.connectedCallback();
}
}
}
customElements.define('data-table', DataTable);
</script>
Shadow DOM encapsulates styles and markup, preventing conflicts with the rest of the page.
Interactive code playground requires JavaScript. Here's the code:
<styled-button></styled-button>
<script>
class StyledButton extends HTMLElement {
connectedCallback() {
// Attach shadow root
const shadow = this.attachShadow({ mode: 'open' });
// Add styles (scoped to shadow DOM)
const style = document.createElement('style');
style.textContent = `
button {
background: linear-gradient(45deg, #667eea, #764ba2);
color: white;
border: none;
padding: 12px 24px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
`;
// Add button element
const button = document.createElement('button');
button.textContent = 'Styled Button';
// Add to shadow DOM
shadow.appendChild(style);
shadow.appendChild(button);
}
}
customElements.define('styled-button', StyledButton);
</script>
Interactive code playground requires JavaScript. Here's the code:
<script>
class ClosedElement extends HTMLElement {
connectedCallback() {
// mode: 'closed' - Cannot access from outside
const shadow = this.attachShadow({ mode: 'closed' });
shadow.textContent = 'Hidden content (closed shadow)';
}
}
class OpenElement extends HTMLElement {
connectedCallback() {
// mode: 'open' - Can access and manipulate from outside
const shadow = this.attachShadow({ mode: 'open' });
shadow.textContent = 'Accessible content (open shadow)';
}
}
customElements.define('closed-element', ClosedElement);
customElements.define('open-element', OpenElement);
</script>
<closed-element></closed-element>
<open-element></open-element>
Slots let users pass content into your component.
Interactive code playground requires JavaScript. Here's the code:
<!-- Component usage with content -->
<card-component>
<h2 slot="title">Card Title</h2>
<p slot="content">This is the card content.</p>
</card-component>
<script>
class CardComponent extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = `
:host {
display: block;
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
background: #fafafa;
}
::slotted([slot="title"]) {
margin-top: 0;
color: #333;
}
::slotted([slot="content"]) {
color: #666;
}
`;
const container = document.createElement('div');
container.innerHTML = `
<slot name="title"></slot>
<slot name="content"></slot>
`;
shadow.appendChild(style);
shadow.appendChild(container);
}
}
customElements.define('card-component', CardComponent);
</script>
HTML templates provide reusable markup that doesn’t render until cloned.
Interactive code playground requires JavaScript. Here's the code:
<!-- Define template -->
<template id="user-card">
<div style="border: 1px solid #ddd; padding: 16px; margin: 8px 0;">
<h3></h3>
<p></p>
</div>
</template>
<!-- Component that uses template -->
<user-list></user-list>
<script>
class UserList extends HTMLElement {
connectedCallback() {
const users = [
{ name: 'Alice', role: 'Developer' },
{ name: 'Bob', role: 'Designer' },
{ name: 'Charlie', role: 'Manager' }
];
const template = document.getElementById('user-card');
users.forEach(user => {
// Clone the template
const clone = template.content.cloneNode(true);
// Update cloned content
clone.querySelector('h3').textContent = user.name;
clone.querySelector('p').textContent = `Role: ${user.role}`;
// Add to component
this.appendChild(clone);
});
}
}
customElements.define('user-list', UserList);
</script>
Interactive code playground requires JavaScript. Here's the code:
<!-- Template with slots -->
<template id="list-template">
<style>
.list-container { border: 1px solid #ddd; }
.list-item { padding: 8px; border-bottom: 1px solid #eee; }
</style>
<div class="list-container">
<slot></slot>
</div>
</template>
<!-- Usage -->
<custom-list>
<div class="list-item">Item 1</div>
<div class="list-item">Item 2</div>
<div class="list-item">Item 3</div>
</custom-list>
<script>
class CustomList extends HTMLElement {
connectedCallback() {
const template = document.getElementById('list-template');
const shadow = this.attachShadow({ mode: 'open' });
shadow.appendChild(template.content.cloneNode(true));
}
}
customElements.define('custom-list', CustomList);
</script>
Interactive code playground requires JavaScript. Here's the code:
<toggle-button
label="Enable notifications"
checked="false">
</toggle-button>
<script>
class ToggleButton extends HTMLElement {
static get observedAttributes() {
return ['checked'];
}
connectedCallback() {
this.render();
this.addEventListener('click', () => this.toggle());
}
toggle() {
const isChecked = this.getAttribute('checked') === 'true';
this.setAttribute('checked', String(!isChecked));
}
attributeChangedCallback() {
this.render();
}
render() {
const isChecked = this.getAttribute('checked') === 'true';
const label = this.getAttribute('label') || 'Toggle';
const shadow = this.shadowRoot || this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host {
display: inline-block;
cursor: pointer;
user-select: none;
}
.toggle {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
}
.switch {
width: 40px;
height: 24px;
background: #ccc;
border-radius: 12px;
position: relative;
transition: background 0.3s;
}
.switch::after {
content: '';
position: absolute;
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
top: 2px;
left: 2px;
transition: left 0.3s;
}
.toggle[data-checked="true"] .switch {
background: #4CAF50;
}
.toggle[data-checked="true"] .switch::after {
left: 18px;
}
</style>
<label class="toggle" data-checked="${isChecked}">
<span class="switch"></span>
<span>${label}</span>
</label>
`;
}
}
customElements.define('toggle-button', ToggleButton);
</script>
Interactive code playground requires JavaScript. Here's the code:
<accordion-item title="What is HTML?">
HTML (HyperText Markup Language) is the standard markup language for creating web pages.
</accordion-item>
<accordion-item title="What is CSS?">
CSS (Cascading Style Sheets) is used to style HTML elements.
</accordion-item>
<script>
class AccordionItem extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: 'open' });
const title = this.getAttribute('title') || 'Section';
shadow.innerHTML = `
<style>
:host {
display: block;
border: 1px solid #ddd;
margin-bottom: 8px;
border-radius: 4px;
}
.header {
padding: 16px;
cursor: pointer;
background: #f5f5f5;
display: flex;
justify-content: space-between;
align-items: center;
user-select: none;
}
.header:hover {
background: #eee;
}
.content {
padding: 16px;
display: none;
}
.content.open {
display: block;
}
.arrow {
transition: transform 0.3s;
}
.arrow.open {
transform: rotate(180deg);
}
</style>
<div class="header">
<span>${title}</span>
<span class="arrow">▼</span>
</div>
<div class="content">
<slot></slot>
</div>
`;
const header = shadow.querySelector('.header');
const content = shadow.querySelector('.content');
const arrow = shadow.querySelector('.arrow');
header.addEventListener('click', () => {
content.classList.toggle('open');
arrow.classList.toggle('open');
});
}
}
customElements.define('accordion-item', AccordionItem);
</script>
Interactive code playground requires JavaScript. Here's the code:
<tabs-component>
<tab-panel label="Tab 1">
Content for tab 1
</tab-panel>
<tab-panel label="Tab 2">
Content for tab 2
</tab-panel>
<tab-panel label="Tab 3">
Content for tab 3
</tab-panel>
</tabs-component>
<script>
class TabsComponent extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
.tabs { display: flex; gap: 0; border-bottom: 2px solid #ddd; }
.tab-button {
padding: 12px 16px;
border: none;
background: white;
cursor: pointer;
border-bottom: 3px solid transparent;
transition: all 0.3s;
}
.tab-button.active {
border-bottom-color: #007bff;
color: #007bff;
}
.panels { padding: 16px; }
</style>
<div class="tabs"></div>
<div class="panels"></div>
`;
const tabsContainer = shadow.querySelector('.tabs');
const panelsContainer = shadow.querySelector('.panels');
const panels = this.querySelectorAll('tab-panel');
panels.forEach((panel, index) => {
const label = panel.getAttribute('label');
// Create tab button
const button = document.createElement('button');
button.className = 'tab-button';
if (index === 0) button.classList.add('active');
button.textContent = label;
button.addEventListener('click', () => this.selectTab(index));
// Create panel
const panelDiv = document.createElement('div');
panelDiv.style.display = index === 0 ? 'block' : 'none';
panelDiv.appendChild(panel.cloneNode(true));
tabsContainer.appendChild(button);
panelsContainer.appendChild(panelDiv);
});
}
selectTab(index) {
const buttons = this.shadowRoot.querySelectorAll('.tab-button');
const panels = this.shadowRoot.querySelectorAll('.panels > div');
buttons.forEach((btn, i) => {
btn.classList.toggle('active', i === index);
});
panels.forEach((panel, i) => {
panel.style.display = i === index ? 'block' : 'none';
});
}
}
class TabPanel extends HTMLElement {
// Just a container, real logic in TabsComponent
}
customElements.define('tabs-component', TabsComponent);
customElements.define('tab-panel', TabPanel);
</script>
Reusable UI components — Buttons, cards, modals with consistent styling
Brand consistency — Enforce company design system across projects
Complex interactions — Encapsulate behavior and styling
Web Components libraries — Create shareable component libraries
Framework integration — Use web components with any framework
Simple styling — Use CSS classes instead
One-off components — If used only once, custom elements add overhead
Heavy logic — Consider lightweight libraries if component is very complex
SEO-critical content — Search engines crawl shadow DOM, but encapsulation may complicate indexing
Web components work well with:
Vue.js — Excellent support
React — Good support, but event handling nuances exist
Angular — Built-in support
Svelte — Excellent support
Vanilla JS — Native support
Interactive code playground requires JavaScript. Here's the code:
<form-field
name="email"
type="email"
label="Email Address"
required="true"
error-message="Please enter a valid email">
</form-field>
<script>
class FormField extends HTMLElement {
static get observedAttributes() {
return ['error'];
}
connectedCallback() {
this.render();
}
attributeChangedCallback() {
if (this.shadowRoot) {
this.render();
}
}
render() {
const shadow = this.shadowRoot || this.attachShadow({ mode: 'open' });
const name = this.getAttribute('name');
const type = this.getAttribute('type') || 'text';
const label = this.getAttribute('label') || '';
const required = this.hasAttribute('required');
const error = this.getAttribute('error');
const errorMsg = this.getAttribute('error-message') || 'Invalid input';
shadow.innerHTML = `
<style>
:host {
display: block;
margin-bottom: 16px;
}
label {
display: block;
margin-bottom: 4px;
font-weight: 500;
color: #333;
}
label::after {
content: ' *';
color: red;
}
label:not([required])::after {
content: '';
}
input {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
}
input.error {
border-color: #dc3545;
}
.error-message {
color: #dc3545;
font-size: 12px;
margin-top: 4px;
display: none;
}
.error-message.show {
display: block;
}
</style>
<label ${required ? 'required' : ''}>${label}</label>
<input
type="${type}"
name="${name}"
class="${error ? 'error' : ''}"
>
<div class="error-message ${error ? 'show' : ''}">${errorMsg}</div>
`;
const input = shadow.querySelector('input');
input.addEventListener('blur', (e) => {
if (required && !e.target.value) {
this.setAttribute('error', 'true');
} else {
this.removeAttribute('error');
}
});
}
get value() {
return this.shadowRoot?.querySelector('input')?.value || '';
}
set value(val) {
const input = this.shadowRoot?.querySelector('input');
if (input) input.value = val;
}
}
customElements.define('form-field', FormField);
</script>
Custom Elements extend HTMLElement to create new HTML tags
Lifecycle callbacks (connectedCallback, etc.) manage component behavior
Attributes pass configuration via HTML; properties access via JavaScript
Shadow DOM encapsulates styles and markup, preventing conflicts
Templates and slots provide flexible, reusable content structures
Custom elements are framework-agnostic and work everywhere
Use custom elements for reusable UI components and design systems
Always clean up in disconnectedCallback to prevent memory leaks
Prefer open shadow DOM for debugging; use closed only when necessary
Progressive Enhancement
Build resilient websites that work without JavaScript.
Continue →
Accessibility
Make sure your custom elements are accessible to everyone.
Continue →