| name | web-components |
| description | Creates reusable custom HTML elements using Web Components standards including Custom Elements, Shadow DOM, templates, and slots. Use when building framework-agnostic components, creating design systems, or when user mentions web components, custom elements, or shadow DOM. |
Web Components
Native browser APIs for creating reusable, encapsulated custom HTML elements.
Quick Start
<!-- Use the component -->
<user-card name="Alice" role="Admin"></user-card>
<script>
class UserCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
}
render() {
this.shadowRoot.innerHTML = `
<style>
.card { padding: 1rem; border: 1px solid #ddd; border-radius: 8px; }
h2 { margin: 0 0 0.5rem; }
.role { color: #666; font-size: 0.9rem; }
</style>
<div class="card">
<h2>${this.getAttribute('name')}</h2>
<span class="role">${this.getAttribute('role')}</span>
</div>
`;
}
}
customElements.define('user-card', UserCard);
</script>
Custom Elements
Basic Definition
class MyButton extends HTMLElement {
// Called when element is created
constructor() {
super();
// Initialize state, attach shadow DOM
}
// Called when element is added to DOM
connectedCallback() {
console.log('Element added to page');
this.render();
}
// Called when element is removed from DOM
disconnectedCallback() {
console.log('Element removed from page');
this.cleanup();
}
// Called when element is moved to new document
adoptedCallback() {
console.log('Element moved to new document');
}
// Called when observed attribute changes
attributeChangedCallback(name, oldValue, newValue) {
console.log(`Attribute ${name} changed from ${oldValue} to ${newValue}`);
this.render();
}
// List of attributes to observe
static get observedAttributes() {
return ['variant', 'disabled', 'size'];
}
}
customElements.define('my-button', MyButton);
Extending Built-in Elements
class FancyButton extends HTMLButtonElement {
constructor() {
super();
this.addEventListener('click', this.handleClick);
}
handleClick() {
this.classList.add('clicked');
}
}
customElements.define('fancy-button', FancyButton, { extends: 'button' });
// Usage: <button is="fancy-button">Click me</button>
Shadow DOM
Open vs Closed Mode
class OpenComponent extends HTMLElement {
constructor() {
super();
// Accessible via element.shadowRoot
this.attachShadow({ mode: 'open' });
}
}
class ClosedComponent extends HTMLElement {
#shadow;
constructor() {
super();
// Not accessible from outside
this.#shadow = this.attachShadow({ mode: 'closed' });
}
}
Style Encapsulation
class StyledComponent extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
/* Styles only apply inside shadow DOM */
:host {
display: block;
padding: 1rem;
border: 1px solid #ccc;
}
/* Style based on host attributes */
:host([variant="primary"]) {
background: #007bff;
color: white;
}
:host([disabled]) {
opacity: 0.5;
pointer-events: none;
}
/* Style when host matches selector */
:host-context(.dark-theme) {
background: #333;
color: white;
}
/* Internal elements */
.title {
font-size: 1.5rem;
font-weight: bold;
}
/* Style slotted content */
::slotted(p) {
margin: 0.5rem 0;
}
::slotted(*) {
color: inherit;
}
</style>
<div class="title"><slot name="title"></slot></div>
<div class="content"><slot></slot></div>
`;
}
}
CSS Parts for External Styling
class PartComponent extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
.header { padding: 1rem; }
.body { padding: 1rem; }
</style>
<div class="header" part="header">
<slot name="header"></slot>
</div>
<div class="body" part="body">
<slot></slot>
</div>
`;
}
}
customElements.define('part-component', PartComponent);
/* External CSS can style parts */
part-component::part(header) {
background: #f5f5f5;
border-bottom: 1px solid #ddd;
}
part-component::part(body) {
background: white;
}
CSS Custom Properties (Theming)
class ThemableComponent extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host {
/* Defaults with fallbacks */
--button-bg: var(--theme-primary, #007bff);
--button-color: var(--theme-text, white);
--button-radius: var(--theme-radius, 4px);
}
button {
background: var(--button-bg);
color: var(--button-color);
border-radius: var(--button-radius);
border: none;
padding: 0.5rem 1rem;
cursor: pointer;
}
</style>
<button><slot></slot></button>
`;
}
}
/* Theme the component from outside */
:root {
--theme-primary: #6366f1;
--theme-text: white;
--theme-radius: 8px;
}
Templates and Slots
HTML Template
<template id="user-card-template">
<style>
.card {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
}
.info h3 {
margin: 0;
font-size: 1rem;
}
.info p {
margin: 0.25rem 0 0;
color: #6b7280;
font-size: 0.875rem;
}
</style>
<div class="card">
<img class="avatar" src="" alt="">
<div class="info">
<h3><slot name="name">Unknown User</slot></h3>
<p><slot name="email">No email</slot></p>
</div>
<div class="actions">
<slot name="actions"></slot>
</div>
</div>
</template>
<script>
class UserCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
const template = document.getElementById('user-card-template');
this.shadowRoot.appendChild(template.content.cloneNode(true));
}
connectedCallback() {
const img = this.shadowRoot.querySelector('.avatar');
img.src = this.getAttribute('avatar') || '/default-avatar.png';
img.alt = this.getAttribute('name') || 'User avatar';
}
}
customElements.define('user-card', UserCard);
</script>
<!-- Usage -->
<user-card avatar="/alice.jpg">
<span slot="name">Alice Johnson</span>
<span slot="email">alice@example.com</span>
<button slot="actions">Follow</button>
</user-card>
Named Slots
class TabPanel extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
.tabs {
display: flex;
border-bottom: 1px solid #ddd;
}
.panels {
padding: 1rem;
}
</style>
<div class="tabs">
<slot name="tab"></slot>
</div>
<div class="panels">
<slot name="panel"></slot>
</div>
`;
}
}
// Usage:
// <tab-panel>
// <button slot="tab">Tab 1</button>
// <button slot="tab">Tab 2</button>
// <div slot="panel">Content 1</div>
// <div slot="panel">Content 2</div>
// </tab-panel>
Slot Events
class SlotContainer extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `<slot></slot>`;
const slot = this.shadowRoot.querySelector('slot');
slot.addEventListener('slotchange', (e) => {
const assigned = slot.assignedElements();
console.log('Slotted elements:', assigned);
// React to content changes
this.updateLayout(assigned);
});
}
updateLayout(elements) {
elements.forEach((el, i) => {
el.style.order = i;
});
}
}
Reactive Properties
Property/Attribute Reflection
class ReactiveElement extends HTMLElement {
static get observedAttributes() {
return ['count', 'disabled'];
}
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._count = 0;
}
// Reflect property to attribute
get count() {
return this._count;
}
set count(value) {
this._count = Number(value);
this.setAttribute('count', this._count);
this.render();
}
// Boolean attribute
get disabled() {
return this.hasAttribute('disabled');
}
set disabled(value) {
if (value) {
this.setAttribute('disabled', '');
} else {
this.removeAttribute('disabled');
}
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'count' && oldValue !== newValue) {
this._count = Number(newValue);
this.render();
}
}
render() {
this.shadowRoot.innerHTML = `
<button ${this.disabled ? 'disabled' : ''}>
Count: ${this.count}
</button>
`;
}
}
Form-Associated Custom Elements
class CustomInput extends HTMLElement {
static formAssociated = true;
constructor() {
super();
this.internals = this.attachInternals();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
input {
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
}
input:focus {
outline: 2px solid #007bff;
}
:host(:invalid) input {
border-color: red;
}
</style>
<input type="text">
`;
this.input = this.shadowRoot.querySelector('input');
this.input.addEventListener('input', () => this.handleInput());
}
handleInput() {
this.internals.setFormValue(this.input.value);
// Validation
if (this.hasAttribute('required') && !this.input.value) {
this.internals.setValidity(
{ valueMissing: true },
'This field is required',
this.input
);
} else {
this.internals.setValidity({});
}
}
// Form lifecycle
formAssociatedCallback(form) {
console.log('Associated with form:', form);
}
formDisabledCallback(disabled) {
this.input.disabled = disabled;
}
formResetCallback() {
this.input.value = '';
this.internals.setFormValue('');
}
// Expose validity state
get validity() { return this.internals.validity; }
get validationMessage() { return this.internals.validationMessage; }
checkValidity() { return this.internals.checkValidity(); }
reportValidity() { return this.internals.reportValidity(); }
}
customElements.define('custom-input', CustomInput);
// Usage in form
// <form>
// <custom-input name="username" required></custom-input>
// <button type="submit">Submit</button>
// </form>
Event Handling
Custom Events
class CounterElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._count = 0;
this.render();
}
increment() {
this._count++;
this.render();
// Dispatch custom event (bubbles through shadow DOM)
this.dispatchEvent(new CustomEvent('count-changed', {
detail: { count: this._count },
bubbles: true,
composed: true // Crosses shadow DOM boundary
}));
}
render() {
this.shadowRoot.innerHTML = `
<button id="btn">Count: ${this._count}</button>
`;
this.shadowRoot.getElementById('btn')
.addEventListener('click', () => this.increment());
}
}
// Listen from outside
document.querySelector('counter-element')
.addEventListener('count-changed', (e) => {
console.log('New count:', e.detail.count);
});
Event Retargeting
class EventDemo extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<button id="inner">Click me</button>
`;
// Event target inside shadow DOM
this.shadowRoot.getElementById('inner')
.addEventListener('click', (e) => {
console.log('Inside shadow:', e.target); // <button>
});
}
}
// Outside shadow DOM, target is retargeted to host
document.querySelector('event-demo')
.addEventListener('click', (e) => {
console.log('Outside shadow:', e.target); // <event-demo>
console.log('Composed path:', e.composedPath()); // Full path
});
Complete Example: Modal Component
class AppModal extends HTMLElement {
static get observedAttributes() {
return ['open'];
}
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host {
display: none;
}
:host([open]) {
display: block;
}
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 8px;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow: auto;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #e5e7eb;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
padding: 0.25rem;
}
.body {
padding: 1rem;
}
.footer {
padding: 1rem;
border-top: 1px solid #e5e7eb;
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
</style>
<div class="backdrop" part="backdrop">
<div class="modal" part="modal" role="dialog" aria-modal="true">
<div class="header" part="header">
<slot name="title"><h2>Modal</h2></slot>
<button class="close-btn" aria-label="Close">×</button>
</div>
<div class="body" part="body">
<slot></slot>
</div>
<div class="footer" part="footer">
<slot name="footer"></slot>
</div>
</div>
</div>
`;
this.backdrop = this.shadowRoot.querySelector('.backdrop');
this.closeBtn = this.shadowRoot.querySelector('.close-btn');
this.closeBtn.addEventListener('click', () => this.close());
this.backdrop.addEventListener('click', (e) => {
if (e.target === this.backdrop) this.close();
});
}
get open() {
return this.hasAttribute('open');
}
set open(value) {
if (value) {
this.setAttribute('open', '');
} else {
this.removeAttribute('open');
}
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'open') {
if (newValue !== null) {
this.trapFocus();
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
}
}
show() {
this.open = true;
this.dispatchEvent(new CustomEvent('modal-open', { bubbles: true, composed: true }));
}
close() {
this.open = false;
this.dispatchEvent(new CustomEvent('modal-close', { bubbles: true, composed: true }));
}
trapFocus() {
const focusable = this.shadowRoot.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
first?.focus();
this.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.close();
}
if (e.key === 'Tab') {
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
});
}
}
customElements.define('app-modal', AppModal);
<!-- Usage -->
<button onclick="document.querySelector('app-modal').show()">
Open Modal
</button>
<app-modal>
<h2 slot="title">Confirm Action</h2>
<p>Are you sure you want to proceed?</p>
<div slot="footer">
<button onclick="this.closest('app-modal').close()">Cancel</button>
<button onclick="handleConfirm()">Confirm</button>
</div>
</app-modal>
Reference Files
- lit-integration.md - Using Lit for simpler web components
- testing.md - Testing web components