· Advanced
Web Components and Custom Elements
Build reusable components with native web standards.
Web Components let you create custom, reusable HTML elements that work everywhere—no framework required. Built on native browser APIs, they encapsulate functionality and styling, making them portable across projects and frameworks. In this guide, you’ll learn how to create custom elements, use shadow DOM, and leverage HTML templates to build truly reusable components.
Web Components are the future of modular web development—write once, use anywhere.
What Are Web Components?
Web Components combine three main technologies:
- Custom Elements - Define your own HTML tags
- Shadow DOM - Encapsulate styles and markup
- HTML Templates - Reusable HTML chunks
Together, they let you create components like:
<user-card name="Jane Smith" email="[email protected]"></user-card>
<countdown-timer end="2024-12-31"></countdown-timer>
<custom-dropdown options='["Option 1", "Option 2"]'></custom-dropdown>Creating a Custom Element
Basic Example
// Define the component
class HelloWorld extends HTMLElement {
constructor() {
super();
this.innerHTML = `<h1>Hello, World!</h1>`;
}
}
// Register it
customElements.define('hello-world', HelloWorld);<!-- Use it in HTML -->
<hello-world></hello-world>With Attributes
class UserGreeting extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
const name = this.getAttribute('name') || 'Guest';
this.innerHTML = `<p>Hello, ${name}!</p>`;
}
}
customElements.define('user-greeting', UserGreeting);<user-greeting name="Jane"></user-greeting>
<!-- Renders: Hello, Jane! -->
<user-greeting></user-greeting>
<!-- Renders: Hello, Guest! -->Lifecycle Callbacks
Custom elements have lifecycle hooks:
class MyElement extends HTMLElement {
constructor() {
super();
console.log('Element created');
}
connectedCallback() {
console.log('Element added to page');
this.render();
}
disconnectedCallback() {
console.log('Element removed from page');
// Cleanup code here
}
attributeChangedCallback(name, oldValue, newValue) {
console.log(`Attribute ${name} changed from ${oldValue} to ${newValue}`);
this.render();
}
static get observedAttributes() {
return ['name', 'age'];
}
render() {
const name = this.getAttribute('name');
const age = this.getAttribute('age');
this.innerHTML = `<p>${name} is ${age} years old</p>`;
}
}
customElements.define('my-element', MyElement);Shadow DOM
Shadow DOM encapsulates styles and markup:
class StyledButton extends HTMLElement {
constructor() {
super();
// Attach shadow root
const shadow = this.attachShadow({ mode: 'open' });
// Add styles (scoped to this component!)
const style = document.createElement('style');
style.textContent = `
button {
background: #0066cc;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
button:hover {
background: #0052a3;
}
`;
// Add button
const button = document.createElement('button');
button.textContent = this.getAttribute('label') || 'Click me';
// Attach to shadow root
shadow.appendChild(style);
shadow.appendChild(button);
}
}
customElements.define('styled-button', StyledButton);<styled-button label="Submit"></styled-button>
<styled-button label="Cancel"></styled-button>
<!-- Each button has its own encapsulated styles -->HTML Templates
Use <template> for reusable markup:
<template id="card-template">
<style>
.card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem;
margin: 1rem 0;
}
.card-title {
font-size: 1.5rem;
margin: 0 0 0.5rem 0;
}
.card-content {
color: #666;
}
</style>
<div class="card">
<h2 class="card-title"></h2>
<p class="card-content"></p>
</div>
</template>
<script>
class InfoCard extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
// Clone template
const template = document.getElementById('card-template');
const clone = template.content.cloneNode(true);
// Fill in content
clone.querySelector('.card-title').textContent = this.getAttribute('title');
clone.querySelector('.card-content').textContent = this.getAttribute('content');
shadow.appendChild(clone);
}
}
customElements.define('info-card', InfoCard);
</script>
<!-- Use the component -->
<info-card
title="Web Components"
content="Build reusable components with native APIs">
</info-card>Slots for Content Projection
Slots let you pass content into components:
class AlertBox extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
.alert {
padding: 1rem;
border-radius: 4px;
margin: 1rem 0;
}
.alert-error { background: #fee; border: 1px solid #fcc; }
.alert-success { background: #efe; border: 1px solid #cfc; }
.alert-warning { background: #ffc; border: 1px solid #fc6; }
.alert-title {
font-weight: bold;
margin-bottom: 0.5rem;
}
</style>
<div class="alert alert-${this.getAttribute('type') || 'error'}">
<div class="alert-title">
<slot name="title">Alert</slot>
</div>
<div class="alert-content">
<slot></slot>
</div>
</div>
`;
}
}
customElements.define('alert-box', AlertBox);<alert-box type="success">
<span slot="title">Success!</span>
Your form was submitted successfully.
</alert-box>
<alert-box type="error">
<span slot="title">Error</span>
Please fill out all required fields.
</alert-box>Reactive Properties
Make components reactive to property changes:
class CounterButton extends HTMLElement {
constructor() {
super();
this._count = 0;
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
button {
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
}
</style>
<button>Count: <span class="count">0</span></button>
`;
this.button = shadow.querySelector('button');
this.countSpan = shadow.querySelector('.count');
this.button.addEventListener('click', () => {
this.count++;
});
}
get count() {
return this._count;
}
set count(value) {
this._count = value;
this.countSpan.textContent = value;
// Dispatch custom event
this.dispatchEvent(new CustomEvent('countchanged', {
detail: { count: value }
}));
}
}
customElements.define('counter-button', CounterButton);<counter-button></counter-button>
<script>
const counter = document.querySelector('counter-button');
counter.addEventListener('countchanged', (e) => {
console.log('Count changed to:', e.detail.count);
});
</script>Complete Example: User Card
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web Components Demo</title>
</head>
<body>
<h1>Team Members</h1>
<user-card
name="Jane Smith"
role="Senior Developer"
email="[email protected]"
avatar="https://i.pravatar.cc/150?img=1">
</user-card>
<user-card
name="John Doe"
role="Designer"
email="[email protected]"
avatar="https://i.pravatar.cc/150?img=2">
</user-card>
<script>
class UserCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
}
attributeChangedCallback() {
this.render();
}
static get observedAttributes() {
return ['name', 'role', 'email', 'avatar'];
}
render() {
const name = this.getAttribute('name') || 'Unknown';
const role = this.getAttribute('role') || 'No role';
const email = this.getAttribute('email') || '[email protected]';
const avatar = this.getAttribute('avatar') || 'https://via.placeholder.com/150';
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
font-family: system-ui, sans-serif;
}
.card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 1.5rem;
margin: 1rem 0;
display: flex;
gap: 1rem;
align-items: center;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.avatar {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
}
.info {
flex: 1;
}
.name {
font-size: 1.25rem;
font-weight: bold;
margin: 0 0 0.25rem 0;
}
.role {
color: #666;
margin: 0 0 0.5rem 0;
}
.email {
color: #0066cc;
text-decoration: none;
}
.email:hover {
text-decoration: underline;
}
</style>
<div class="card">
<img class="avatar" src="${avatar}" alt="${name}">
<div class="info">
<h2 class="name">${name}</h2>
<p class="role">${role}</p>
<a class="email" href="mailto:${email}">${email}</a>
</div>
</div>
`;
}
}
customElements.define('user-card', UserCard);
</script>
</body>
</html>Using Web Components with Frameworks
Web Components work with any framework:
// React
function App() {
return <user-card name="Jane Smith" role="Developer"></user-card>;
}
// Vue
<template>
<user-card name="Jane Smith" role="Developer"></user-card>
</template>
// Angular
<user-card name="Jane Smith" role="Developer"></user-card>
// Plain HTML
<user-card name="Jane Smith" role="Developer"></user-card>Best Practices
✅ Use semantic names - user-card not uc ✅ Always include hyphens - Required for custom elements ✅ Encapsulate styles - Use Shadow DOM ✅ Handle attributes - Use observedAttributes and lifecycle callbacks ✅ Emit custom events - For component communication ✅ Provide fallback content - For browsers without support ✅ Document your API - List available attributes and events ✅ Test across browsers - Ensure compatibility
Browser Support
Web Components are supported in all modern browsers:
- Chrome/Edge (full support)
- Firefox (full support)
- Safari (full support)
- For older browsers, use polyfills
Common Pitfalls
❌ Forgetting the hyphen - Custom element names must include a hyphen ❌ Not using Shadow DOM - Styles leak without encapsulation ❌ Blocking the main thread - Keep constructors lightweight ❌ Not handling attribute changes - Implement attributeChangedCallback ❌ Overusing - Not everything needs to be a component
Keep Learning
- HTML5 Semantic Elements - Build on HTML fundamentals
- HTML Basics - Build your HTML foundation for components
- Web Performance - Optimize components
- Explore Templates - See components in action
Try building Web Components in the htmlEditor.net playground today!
Web Components represent the future of reusable web development. With native browser support and framework-agnostic compatibility, they’re a powerful tool for building modular, maintainable applications.