Web Components are a suite of different technologies allowing you to create reusable custom elements with their functionality encapsulated away from the rest of your code. This comprehensive guide covers everything you need to know.
The Three Pillars
Web Components consist of three main technologies:
- Custom Elements - Define new HTML tags
- Shadow DOM - Encapsulated style and markup
- HTML Templates - Reusable markup templates
flowchart TD
subgraph WebComponents["Web Components"]
CE["Custom Elements<br/>defineElement()"]
SD["Shadow DOM<br/>attachShadow()"]
HT["HTML Templates<br/><template>"]
end
CE --> Combined["Reusable Components"]
SD --> Combined
HT --> Combined
Custom Elements
Defining a Custom Element
class MyElement extends HTMLElement {
constructor() {
super();
// Initialize component
}
connectedCallback() {
// Element added to DOM
console.log('MyElement added to page');
}
disconnectedCallback() {
// Element removed from DOM
console.log('MyElement removed from page');
}
attributeChangedCallback(name, oldValue, newValue) {
// Attribute changed
console.log(`Attribute ${name} changed from ${oldValue} to ${newValue}`);
}
static get observedAttributes() {
return ['data', 'variant', 'disabled'];
}
}
// Register the custom element
customElements.define('my-element', MyElement);
<!-- Use the custom element -->
<my-element data="value" variant="primary"></my-element>
Autonomous vs Customized Built-in Elements
// Autonomous custom element (entirely new)
class FancyButton extends HTMLElement {
constructor() {
super();
}
}
customElements.define('fancy-button', FancyButton);
// Customized built-in element (extends existing)
class FancyDiv extends HTMLDivElement {
constructor() {
super();
}
}
customElements.define('fancy-div', FancyDiv, { extends: 'div' });
<!-- Autonomous -->
<fancy-button>Click me</fancy-button>
<!-- Customized built-in -->
<div is="fancy-div">I'm fancy</div>
Shadow DOM
Creating Shadow DOM
class MyComponent extends HTMLElement {
constructor() {
super();
// Attach shadow DOM - 'open' allows access from JS
this.attachShadow({ mode: 'open' });
// 'closed' hides shadow root
// this.attachShadow({ mode: 'closed' });
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
padding: 1rem;
}
:host([hidden]) {
display: none;
}
.content {
color: #333;
}
</style>
<div class="content">
<slot></slot>
</div>
`;
}
}
:host Selectors
/* The host element itself */
:host {
display: block;
}
/* Based on host's attributes */
:host([theme="dark"]) {
background: #1a1a1a;
color: white;
}
:host([disabled]) {
opacity: 0.5;
pointer-events: none;
}
/* Context-based styling */
:host-context(.dark-mode) {
background: #333;
}
Slots and Content Distribution
<!-- Component template -->
<template id="my-card-template">
<style>
.card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem;
}
.header {
font-weight: bold;
margin-bottom: 0.5rem;
}
</style>
<div class="card">
<div class="header">
<slot name="header">Default Title</slot>
</div>
<div class="body">
<slot></slot>
</div>
</div>
</template>
<!-- Using the component -->
<my-card>
<span slot="header">Custom Title</span>
<p>This is the body content.</p>
</my-card>
// Named slots in JavaScript
class MyComponent extends HTMLElement {
connectedCallback() {
const slot = this.shadowRoot.querySelector('slot[name="header"]');
slot.addEventListener('slotchange', (e) => {
const assignedElements = slot.assignedElements();
console.log('Header content:', assignedElements);
});
}
}
HTML Templates
Using Templates
<template id="my-template">
<style>
p {
color: blue;
}
</style>
<p>Template content</p>
</template>
<script>
const template = document.getElementById('my-template');
class MyElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
// Clone the template
const clone = template.content.cloneNode(true);
this.shadowRoot.appendChild(clone);
}
}
customElements.define('my-element', MyElement);
</script>
Template with Parameters
class UserCard extends HTMLElement {
static get observedAttributes() {
return ['name', 'email', 'avatar'];
}
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
}
attributeChangedCallback() {
this.render();
}
render() {
const name = this.getAttribute('name') || 'Unknown';
const email = this.getAttribute('email') || '';
const avatar = this.getAttribute('avatar') || 'default.png';
this.shadowRoot.innerHTML = `
<style>
:host {
display: inline-block;
}
.card {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
border: 1px solid #ddd;
border-radius: 8px;
}
img {
width: 48px;
height: 48px;
border-radius: 50%;
}
.info h3 {
margin: 0;
font-size: 1rem;
}
.info p {
margin: 0;
color: #666;
font-size: 0.875rem;
}
</style>
<div class="card">
<img src="${avatar}" alt="${name}">
<div class="info">
<h3>${name}</h3>
<p>${email}</p>
</div>
</div>
`;
}
}
customElements.define('user-card', UserCard);
Lifecycle Callbacks
Complete Lifecycle
class LifecycleElement extends HTMLElement {
constructor() {
super();
console.log('1. constructor() - Element created');
}
static get observedAttributes() {
return ['data', 'count'];
}
connectedCallback() {
console.log('2. connectedCallback() - Added to DOM');
// Initial setup
}
disconnectedCallback() {
console.log('3. disconnectedCallback() - Removed from DOM');
// Cleanup
}
attributeChangedCallback(name, oldValue, newValue) {
console.log(`4. attributeChangedCallback() - ${name}: ${oldValue} โ ${newValue}`);
}
adoptedCallback() {
console.log('5. adoptedCallback() - Moved to new document');
}
}
Cleanup in disconnectedCallback
class EventElement extends HTMLElement {
connectedCallback() {
// Add event listeners
this.handleClick = this.handleClick.bind(this);
this.addEventListener('click', this.handleClick);
// Start timers
this.timer = setInterval(() => this.tick(), 1000);
}
disconnectedCallback() {
// Clean up!
this.removeEventListener('click', this.handleClick);
clearInterval(this.timer);
}
handleClick(e) {
console.log('Clicked!');
}
tick() {
// Do something
}
}
Properties and Attributes
Reflecting Properties
class ReflectElement extends HTMLElement {
// Define getter/setter
get active() {
return this.hasAttribute('active');
}
set active(value) {
// Reflect to attribute
if (value) {
this.setAttribute('active', '');
} else {
this.removeAttribute('active');
}
}
// Or use static property
static get observedAttributes() {
return ['active', 'value'];
}
attributeChangedCallback(name, oldValue, newValue) {
// Sync attribute โ property
if (name === 'active') {
this._active = newValue !== null;
}
}
}
Complex Properties
class DataElement extends HTMLElement {
get items() {
return this._items || [];
}
set items(value) {
this._items = value;
this.render();
}
// JSON attribute
get config() {
try {
return JSON.parse(this.getAttribute('config'));
} catch {
return {};
}
}
set config(value) {
this.setAttribute('config', JSON.stringify(value));
}
}
Events and Custom Events
Dispatching Events
class ButtonElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<button type="button"><slot></slot></button>
`;
this.button = this.shadowRoot.querySelector('button');
this.button.addEventListener('click', this.handleClick.bind(this));
}
handleClick(e) {
// Dispatch custom event
this.dispatchEvent(new CustomEvent('button-click', {
bubbles: true,
composed: true,
detail: {
timestamp: Date.now()
}
}));
}
}
// Listen for custom event
document.querySelector('my-button').addEventListener('button-click', (e) => {
console.log('Button clicked at:', e.detail.timestamp);
});
Changing Events
class FormInput extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<input type="text">
`;
this.input = this.shadowRoot.querySelector('input');
this.input.addEventListener('input', this.handleInput.bind(this));
this.input.addEventListener('change', this.handleChange.bind(this));
}
handleInput(e) {
// Dispatch input event that bubbles through shadow boundary
this.dispatchEvent(new Event('input', {
bubbles: true,
composed: true
}));
}
handleChange(e) {
this.dispatchEvent(new Event('change', {
bubbles: true,
composed: true
}));
}
}
Styling Best Practices
Encapsulated Styles
class StyledComponent extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
}
/* Component styles */
.container {
padding: 1rem;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
/* Use CSS variables for theming */
:host([theme="dark"]) .container {
background: #1a1a1a;
color: white;
}
/* Focus styles */
:host(:focus-within) {
outline: 2px solid blue;
}
</style>
<div class="container">
<slot></slot>
</div>
`;
}
}
Inheriting Styles
class TextComponent extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
/* Inherit font from document */
:host {
font-family: inherit;
font-size: inherit;
color: inherit;
}
</style>
<p><slot></slot></p>
</div>
`;
}
}
Forms and Web Components
Making Components Work with Forms
class FancyInput extends HTMLElement {
static formAssociated = true;
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.internals = this.attachInternals();
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<input type="text">
`;
this.input = this.shadowRoot.querySelector('input');
this.input.addEventListener('input', () => {
// Update value for form
this.internals.setFormValue(this.input.value);
});
this.input.addEventListener('invalid', () => {
this.internals.setValidity(
{ customError: true },
'Please enter a value'
);
});
}
// Form lifecycle
formDisabledCallback(disabled) {
this.input.disabled = disabled;
}
formResetCallback() {
this.input.value = '';
this.internals.setValidity({});
}
formStateRestoreCallback(state, mode) {
this.input.value = state;
}
}
<!-- Works with native forms -->
<form>
<fancy-input name="username" required></fancy-input>
<button type="submit">Submit</button>
</form>
Use Cases
Modal Dialog
class ModalDialog extends HTMLElement {
static get observedAttributes() {
return ['open', 'title'];
}
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
}
attributeChangedCallback() {
this.render();
}
render() {
const open = this.hasAttribute('open');
const title = this.getAttribute('title') || 'Modal';
this.shadowRoot.innerHTML = `
<style>
:host {
display: ${open ? 'flex' : 'none'};
}
.overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
}
.modal {
background: white;
border-radius: 8px;
padding: 1.5rem;
min-width: 300px;
max-width: 90vw;
}
header {
display: flex;
justify-content: space-between;
margin-bottom: 1rem;
}
button.close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
}
</style>
<div class="overlay" part="overlay">
<div class="modal" part="modal">
<header>
<h2>${title}</h2>
<button class="close" onclick="this.closest('modal-dialog').close()">ร</button>
</header>
<div class="content">
<slot></slot>
</div>
</div>
</div>
`;
}
close() {
this.removeAttribute('open');
}
show() {
this.setAttribute('open', '');
}
}
customElements.define('modal-dialog', ModalDialog);
Tabs Component
class TabGroup extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._selectedTab = null;
}
connectedCallback() {
this.render();
// Setup tab switching
this.shadowRoot.querySelectorAll('[role="tab"]').forEach(tab => {
tab.addEventListener('click', () => this.selectTab(tab));
});
}
selectTab(tab) {
const tabName = tab.dataset.tab;
// Update tab states
this.shadowRoot.querySelectorAll('[role="tab"]').forEach(t => {
t.setAttribute('aria-selected', t === tab);
});
// Update panel visibility
this.shadowRoot.querySelectorAll('[role="tabpanel"]').forEach(panel => {
if (panel.dataset.tab === tabName) {
panel.hidden = false;
} else {
panel.hidden = true;
}
});
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
}
[role="tablist"] {
display: flex;
border-bottom: 1px solid #ddd;
}
[role="tab"] {
padding: 0.75rem 1rem;
background: none;
border: none;
cursor: pointer;
border-bottom: 2px solid transparent;
}
[role="tab"][aria-selected="true"] {
border-bottom-color: blue;
}
[role="tabpanel"] {
padding: 1rem;
}
[role="tabpanel"][hidden] {
display: none;
}
</style>
<div role="tablist">
<slot name="tab"></slot>
</div>
<slot name="panel"></slot>
`;
}
}
customElements.define('tab-group', TabGroup);
External Resources
Conclusion
Web Components provide a native way to create reusable, encapsulated HTML elements. Key points:
- Use
customElements.define()to register new elements - Use Shadow DOM for style encapsulation
- Use
<template>for reusable markup - Implement lifecycle callbacks:
connectedCallback,disconnectedCallback, etc. - Reflect properties to attributes for reactivity
- Dispatch custom events for communication
Web Components work with any framework (React, Vue, Angular) and can be used in plain HTML pages. They provide true framework-agnostic reusability.
Comments