Table of Contents
- What Are Web Components?
- Core Concepts of Web Components
- Creating Your First Custom Element
- Lifecycle Callbacks
- Using Shadow DOM for Encapsulation
- HTML Templates and Slots for Flexible Content
- Advanced Features
- Best Practices
- Conclusion
- References
What Are Web Components?
Web Components are a collection of four W3C standards that enable the creation of reusable, self-contained HTML elements. They work natively in modern browsers (Chrome, Firefox, Safari, Edge) without needing frameworks or libraries. The key standards are:
- Custom Elements: Define new HTML element types (e.g.,
<user-profile>). - Shadow DOM: Encapsulate styles and markup to prevent conflicts with the rest of the page.
- HTML Templates: Define inert markup that can be cloned and reused.
- ES Modules: Package and import components across projects.
Together, these standards let you build components that are:
- Reusable: Use them across projects and frameworks.
- Encapsulated: Styles and logic don’t leak into or from the component.
- Native: No runtime dependencies—just vanilla JavaScript.
Core Concepts of Web Components
Before diving into creation, let’s break down the foundational concepts:
1. Custom Elements
Custom Elements let you define new HTML elements with custom behavior. There are two types:
- Autonomous Custom Elements: Standalone elements that don’t extend existing HTML elements (e.g.,
<my-component>). - Customized Built-in Elements: Extend native HTML elements (e.g.,
<button is="fancy-button">to enhance a<button>).
Custom element names must include a hyphen (e.g., <user-card>, not <usercard>) to avoid conflicts with future HTML standards.
2. Shadow DOM
Shadow DOM is a subtree attached to a custom element that is isolated from the main DOM. It encapsulates:
- Markup: The component’s internal HTML.
- Styles: CSS defined in the shadow DOM only affects the component, and external styles don’t leak in (unless explicitly allowed).
To attach a shadow DOM to an element, use element.attachShadow({ mode: 'open' }), where mode: 'open' allows external JavaScript to access the shadow root (via element.shadowRoot), and mode: 'closed' restricts access.
3. HTML Templates & Slots
<template>: A container for inert markup that isn’t rendered until cloned. Use<template id="my-template">to define reusable markup, then clone it withtemplate.content.cloneNode(true).<slot>: A placeholder for content passed into the component (similar to “props” in frameworks). Slots let users customize a component’s content while keeping its structure intact.
4. ES Modules
Web Components are typically packaged as ES Modules (.js files) using export and import. This makes them easy to share across projects and integrate into build tools like Webpack or Vite.
Creating Your First Custom Element
Let’s build a simple autonomous custom element: <hello-world>, which displays a greeting and accepts a name attribute to personalize it.
Step 1: Define the Element Class
Custom elements are defined by extending HTMLElement (or a built-in element like HTMLButtonElement for customized built-ins). The class contains the element’s logic, including lifecycle callbacks and methods.
// hello-world.js
class HelloWorld extends HTMLElement {
constructor() {
super(); // Always call super() first in the constructor
this.attachShadow({ mode: 'open' }); // Attach shadow DOM
}
// Called when the element is added to the DOM
connectedCallback() {
const name = this.getAttribute('name') || 'Guest';
this.shadowRoot.innerHTML = `
<style>
.greeting { color: #2c3e50; font-family: Arial, sans-serif; }
</style>
<div class="greeting">Hello, ${name}!</div>
`;
}
}
Step 2: Register the Element
To make the browser recognize your custom element, register it with customElements.define(), specifying the element name and class:
// Register the element
customElements.define('hello-world', HelloWorld);
Step 3: Use the Element in HTML
Now you can use <hello-world> like any native HTML element. Pass a name attribute to customize the greeting:
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
<title>Custom Elements Demo</title>
<script type="module" src="hello-world.js"></script> <!-- Import the module -->
</head>
<body>
<hello-world name="Alice"></hello-world> <!-- Renders: "Hello, Alice!" -->
<hello-world></hello-world> <!-- Renders: "Hello, Guest!" -->
</body>
</html>
How It Works:
- The
HelloWorldclass extendsHTMLElement, inheriting all properties/methods of a standard HTML element. constructor()initializes the element and attaches an open shadow root.connectedCallback()runs when the element is added to the DOM. It reads thenameattribute, defines shadow DOM styles, and sets the content.
Lifecycle Callbacks
Custom elements have lifecycle callbacks that trigger at specific stages, letting you hook into their creation, update, and destruction. Here are the key ones:
constructor()
- Triggered: When the element is created (e.g., with
document.createElement('hello-world')). - Purpose: Initialize state, set up event listeners, attach shadow DOM.
- Rules: Must call
super()first. Avoid adding DOM content here (useconnectedCallbackinstead, as the element may not be in the DOM yet).
constructor() {
super();
this.isActive = false; // Initialize state
this.shadowRoot = this.attachShadow({ mode: 'open' });
}
connectedCallback()
- Triggered: When the element is added to the DOM (including re-additions after removal).
- Purpose: Render content, fetch data, start timers, or set up subscriptions.
connectedCallback() {
this.render(); // Update shadow DOM
this.addEventListener('click', this.handleClick);
}
disconnectedCallback()
- Triggered: When the element is removed from the DOM.
- Purpose: Clean up resources (e.g., event listeners, timers, subscriptions) to prevent memory leaks.
disconnectedCallback() {
this.removeEventListener('click', this.handleClick);
}
attributeChangedCallback(attrName, oldValue, newValue)
- Triggered: When an observed attribute is added, removed, or modified.
- Requirement: Define
static get observedAttributes()to specify which attributes to watch.
class HelloWorld extends HTMLElement {
static get observedAttributes() {
return ['name']; // Watch for changes to the "name" attribute
}
attributeChangedCallback(attrName, oldValue, newValue) {
if (attrName === 'name' && oldValue !== newValue) {
this.render(); // Re-render when "name" changes
}
}
render() {
const name = this.getAttribute('name') || 'Guest';
this.shadowRoot.innerHTML = `<div>Hello, ${name}!</div>`;
}
}
adoptedCallback()
- Triggered: When the element is moved to a new document (e.g., an
<iframe>). - Use Case: Rare, but useful for cross-document state synchronization.
Using Shadow DOM for Encapsulation
Shadow DOM is what makes Web Components truly encapsulated. Let’s enhance our <hello-world> component with shadow DOM styles to see how encapsulation works.
Example: Encapsulated Styles
class HelloWorld extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
/* This style only affects the shadow DOM */
.greeting {
color: #3498db;
font-size: 2rem;
padding: 1rem;
border: 2px solid #3498db;
border-radius: 8px;
}
</style>
<div class="greeting">Hello, ${this.getAttribute('name') || 'Guest'}!</div>
`;
}
}
customElements.define('hello-world', HelloWorld);
Key Takeaway:
If you add a global style like .greeting { color: red; } to the main page, it won’t affect the <hello-world> component—its shadow DOM styles take precedence. Conversely, the component’s .greeting style won’t leak out to other elements on the page.
HTML Templates and Slots for Flexible Content
Templates and slots let you define reusable markup and inject custom content into components. Let’s build a <user-card> component that accepts custom avatar, name, and bio via slots.
Step 1: Define a Template
First, define a <template> in your HTML (or import it via JavaScript):
<!-- index.html -->
<template id="user-card-template">
<style>
.card {
width: 300px;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 1.5rem;
font-family: sans-serif;
}
.avatar {
width: 100px;
height: 100px;
border-radius: 50%;
object-fit: cover;
}
.name { font-size: 1.5rem; margin: 1rem 0 0.5rem; }
.bio { color: #666; }
</style>
<div class="card">
<!-- Slot for avatar -->
<slot name="avatar">
<!-- Fallback content if no avatar is provided -->
<img class="avatar" src="default-avatar.png" alt="Default avatar">
</slot>
<h2 class="name"><slot name="name">Guest</slot></h2>
<p class="bio"><slot name="bio">No bio provided.</slot></p>
</div>
</template>
Step 2: Create the Custom Element
Now, clone the template and attach it to the shadow DOM of <user-card>:
// user-card.js
class UserCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
// Clone the template content
const template = document.getElementById('user-card-template');
const clone = template.content.cloneNode(true);
this.shadowRoot.appendChild(clone);
}
}
customElements.define('user-card', UserCard);
Step 3: Use Slots to Inject Content
Users of <user-card> can now pass custom content into the named slots:
<user-card>
<img slot="avatar" class="avatar" src="alice.jpg" alt="Alice">
<span slot="name">Alice Smith</span>
<p slot="bio">Frontend developer passionate about Web Components and accessibility.</p>
</user-card>
How It Works:
- The
<template>defines the component’s structure and styles. - Slots (
name="avatar",name="name", etc.) act as placeholders for user-provided content. - If no content is provided for a slot, the template’s fallback content (e.g., the default avatar) is used.
Advanced Features
Custom Events
Components often need to communicate with their parent. Use custom events to emit data from a Web Component.
Example: A <like-button> that emits a like-changed event when clicked:
class LikeButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.isLiked = false;
}
connectedCallback() {
this.render();
this.shadowRoot.querySelector('button').addEventListener('click', () => {
this.isLiked = !this.isLiked;
this.render();
// Emit a custom event with the new state
this.dispatchEvent(new CustomEvent('like-changed', {
detail: { isLiked: this.isLiked },
bubbles: true, // Let the event bubble up the DOM
cancelable: true
}));
});
}
render() {
this.shadowRoot.innerHTML = `
<style>
button {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
}
.liked { background: #e74c3c; color: white; }
.not-liked { background: #ecf0f1; }
</style>
<button class="${this.isLiked ? 'liked' : 'not-liked'}">
${this.isLiked ? '❤️ Liked' : '🤍 Like'}
</button>
`;
}
}
customElements.define('like-button', LikeButton);
Parent components can listen to the event:
<like-button></like-button>
<script>
document.querySelector('like-button').addEventListener('like-changed', (e) => {
console.log('Like state:', e.detail.isLiked); // Logs true/false
});
</script>
Extending Built-in Elements
You can extend native HTML elements (e.g., <button>, <input>) to add custom behavior. Use the extends option in customElements.define().
Example: A <fancy-button> that extends <button>:
class FancyButton extends HTMLButtonElement {
constructor() {
super(); // Call the parent constructor (HTMLButtonElement)
this.style.backgroundColor = '#3498db';
this.style.color = 'white';
this.style.padding = '0.75rem 1.5rem';
this.style.borderRadius = '4px';
this.style.border = 'none';
}
}
// Register as a customized built-in element
customElements.define('fancy-button', FancyButton, { extends: 'button' });
Use it in HTML with the is attribute:
<button is="fancy-button">Click Me</button>
Best Practices
To build robust Web Components, follow these best practices:
- Use Kebab-Case Names: Ensure element names have a hyphen (e.g.,
<user-card>, not<UserCard>) to avoid conflicts with native elements. - Encapsulate with Shadow DOM: Always use shadow DOM to prevent style and markup leaks.
- Optimize Rendering: Avoid heavy DOM updates in
connectedCallback—userequestAnimationFramefor complex renders. - Handle Accessibility: Use ARIA roles (
role="button"), labels, and keyboard navigation (e.g.,tabindex). - Test Across Browsers: While modern browsers support Web Components, test edge cases (e.g., Safari’s shadow DOM quirks).
- Document APIs: Clearly document attributes, events, and slots so users know how to interact with your component.
Conclusion
Web Components empower you to build reusable, encapsulated, and framework-agnostic UI elements using native browser APIs. By combining Custom Elements, Shadow DOM, Templates, and ES Modules, you can create components that work seamlessly across projects and tools.
Start small—build a simple component like a <alert-box> or <star-rating>, then experiment with advanced features like custom events and slots. With Web Components, the web becomes your canvas for consistent, maintainable UI.
References
- MDN Web Components Guide
- W3C Custom Elements Specification
- Shadow DOM Specification
- Web Components.dev (Interactive playground)
- Open-wc (Tools and best practices for Web Components)