Advanced Features
Web Components
Custom Elements, Shadow DOM, HTML Templates, and Slots — building framework-agnostic, encapsulated UI components
Web Components
Web Components is a suite of browser-native technologies for creating reusable, encapsulated custom HTML elements that work across any framework or no framework at all.
Problems Solved
| Problem | How Web Components Solve It |
|---|---|
| CSS leaks across components | Shadow DOM provides style encapsulation |
| Framework lock-in for shared UI | Custom Elements work everywhere |
| No native component model in HTML | Custom Elements extend HTML vocabulary |
| Markup duplication | HTML Templates enable declarative reuse |
| Tight coupling of component internals | Shadow DOM hides implementation details |
Architecture Overview
Web Components
├── Custom Elements Define new HTML tags with lifecycle
├── Shadow DOM Encapsulated DOM subtree and styles
├── HTML Templates Inert, reusable markup fragments
└── Slots Content projection from light DOMCustom Elements
Defining a Custom Element
class UserCard extends HTMLElement {
// Called when element is added to the DOM
connectedCallback() {
const name = this.getAttribute('name') || 'Unknown';
const role = this.getAttribute('role') || '';
this.innerHTML = `
<div class="card">
<h3>${name}</h3>
<p>${role}</p>
</div>
`;
}
// Observed attributes for attributeChangedCallback
static get observedAttributes() {
return ['name', 'role'];
}
// Called when observed attributes change
attributeChangedCallback(attr, oldVal, newVal) {
if (oldVal !== newVal) {
this.connectedCallback(); // Re-render
}
}
// Called when element is removed from the DOM
disconnectedCallback() {
// Cleanup: remove event listeners, cancel timers, etc.
}
}
// Register the element — name must contain a hyphen
customElements.define('user-card', UserCard);<!-- Usage in HTML -->
<user-card name="Alice" role="Engineer"></user-card>Lifecycle Callbacks
| Callback | When It Fires | Common Use |
|---|---|---|
constructor() | Element created | Initialize state, attach Shadow DOM |
connectedCallback() | Added to DOM | Render, attach listeners, fetch data |
disconnectedCallback() | Removed from DOM | Cleanup listeners, cancel requests |
attributeChangedCallback() | Attribute modified | Update rendering |
adoptedCallback() | Moved to new document | Re-initialize context |
Extending Built-in Elements
class FancyButton extends HTMLButtonElement {
connectedCallback() {
this.style.background = 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)';
this.style.color = 'white';
this.style.border = 'none';
this.style.padding = '8px 16px';
this.style.borderRadius = '4px';
this.style.cursor = 'pointer';
}
}
customElements.define('fancy-button', FancyButton, { extends: 'button' });<!-- Must use "is" attribute for customized built-ins -->
<button is="fancy-button">Click Me</button>Shadow DOM
Shadow DOM creates an encapsulated DOM subtree where styles and markup are isolated from the rest of the page.
Attaching Shadow DOM
class StyledCard extends HTMLElement {
constructor() {
super();
// mode: 'open' allows external JS access via element.shadowRoot
// mode: 'closed' hides the shadow root entirely
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
/* These styles are scoped — won't leak out */
:host {
display: block;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 16px;
}
:host([variant="highlighted"]) {
border-color: #667eea;
background: #f7f8ff;
}
h3 { margin: 0 0 8px; color: #1a202c; }
p { margin: 0; color: #718096; }
/* ::slotted() styles projected content */
::slotted(a) { color: #667eea; }
</style>
<h3><slot name="title">Default Title</slot></h3>
<p><slot>Default content</slot></p>
`;
}
}
customElements.define('styled-card', StyledCard);<styled-card variant="highlighted">
<span slot="title">Custom Title</span>
This is the projected content.
</styled-card>Shadow DOM Boundaries
Document DOM (Light DOM)
├── <styled-card>
│ ├── #shadow-root (open) ← Shadow boundary
│ │ ├── <style>...</style> ← Scoped styles
│ │ ├── <h3><slot name="title"> ← Named slot
│ │ └── <p><slot> ← Default slot
│ ├── <span slot="title"> ← Light DOM, projected into named slot
│ └── "This is the content" ← Light DOM, projected into default slotStyle Encapsulation Rules
| Selector | Where It Works | Purpose |
|---|---|---|
:host | Inside shadow | Style the host element itself |
:host([attr]) | Inside shadow | Style host based on attributes |
:host-context(.class) | Inside shadow | Style based on ancestor context |
::slotted(selector) | Inside shadow | Style projected light DOM content |
::part(name) | Outside shadow | Style exported shadow DOM parts |
| CSS custom properties | Cross boundary | Theming via --var inheritance |
Theming with CSS Custom Properties
CSS custom properties cross the shadow boundary, making them ideal for theming:
class ThemeButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>
button {
background: var(--btn-bg, #3182ce);
color: var(--btn-color, white);
padding: var(--btn-padding, 8px 16px);
border: none;
border-radius: var(--btn-radius, 4px);
font-size: var(--btn-font-size, 14px);
cursor: pointer;
}
button:hover {
opacity: 0.9;
}
</style>
<button><slot>Button</slot></button>
`;
}
}
customElements.define('theme-button', ThemeButton);/* Theme from outside */
theme-button {
--btn-bg: #e53e3e;
--btn-radius: 20px;
--btn-padding: 12px 24px;
}Exposing Parts with ::part()
class FancyInput extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>
.wrapper { display: flex; flex-direction: column; gap: 4px; }
label { font-size: 12px; font-weight: 600; }
input { padding: 8px; border: 1px solid #ccc; border-radius: 4px; }
</style>
<div class="wrapper">
<label part="label"><slot name="label">Label</slot></label>
<input part="input" />
</div>
`;
}
}
customElements.define('fancy-input', FancyInput);/* External styles targeting exported parts */
fancy-input::part(label) { color: #2d3748; }
fancy-input::part(input) { border-color: #667eea; }
fancy-input::part(input):focus { outline: 2px solid #667eea; }HTML Templates
<template> elements hold inert markup that isn't rendered until cloned and inserted.
<template id="card-template">
<div class="card">
<h3 class="card-title"></h3>
<p class="card-body"></p>
<button class="card-action">Action</button>
</div>
</template>
<script>
function createCard(title, body) {
const template = document.getElementById('card-template');
const clone = template.content.cloneNode(true);
clone.querySelector('.card-title').textContent = title;
clone.querySelector('.card-body').textContent = body;
return clone;
}
// Stamp out multiple cards from the same template
document.body.append(
createCard('Card 1', 'First card content'),
createCard('Card 2', 'Second card content')
);
</script>Slots
Slots allow consumers to inject content into predefined locations inside a shadow DOM.
Named and Default Slots
class AppLayout extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>
:host { display: grid; grid-template-rows: auto 1fr auto; min-height: 100vh; }
header { background: #1a202c; color: white; padding: 16px; }
main { padding: 24px; }
footer { background: #f7fafc; padding: 16px; text-align: center; }
</style>
<header><slot name="header">Default Header</slot></header>
<main><slot>Main Content</slot></main>
<footer><slot name="footer">Default Footer</slot></footer>
`;
}
}
customElements.define('app-layout', AppLayout);<app-layout>
<nav slot="header">My App Navigation</nav>
<article>This goes into the default slot (main area)</article>
<p slot="footer">© 2025 My App</p>
</app-layout>Slot Change Events
class DynamicList extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<div class="count">Items: 0</div>
<slot></slot>
`;
const slot = shadow.querySelector('slot');
slot.addEventListener('slotchange', () => {
const items = slot.assignedElements();
shadow.querySelector('.count').textContent = `Items: ${items.length}`;
});
}
}
customElements.define('dynamic-list', DynamicList);Declarative Shadow DOM
Server-rendered shadow DOM without JavaScript:
<styled-card>
<template shadowrootmode="open">
<style>
:host { display: block; padding: 16px; border: 1px solid #ccc; }
</style>
<slot></slot>
</template>
<p>This content is visible immediately, no JS needed.</p>
</styled-card>Real-World Use Cases
Design System Components
// Framework-agnostic design system
class DsButton extends HTMLElement {
static get observedAttributes() {
return ['variant', 'size', 'disabled'];
}
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() { this.render(); }
attributeChangedCallback() { this.render(); }
render() {
const variant = this.getAttribute('variant') || 'primary';
const size = this.getAttribute('size') || 'md';
const styles = {
primary: 'background: #3182ce; color: white;',
secondary: 'background: #edf2f7; color: #1a202c;',
danger: 'background: #e53e3e; color: white;',
};
const sizes = {
sm: 'padding: 4px 8px; font-size: 12px;',
md: 'padding: 8px 16px; font-size: 14px;',
lg: 'padding: 12px 24px; font-size: 16px;',
};
this.shadowRoot.innerHTML = `
<style>
button {
${styles[variant] || styles.primary}
${sizes[size] || sizes.md}
border: none; border-radius: 4px; cursor: pointer;
font-family: inherit;
}
button:hover { opacity: 0.9; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
</style>
<button ${this.hasAttribute('disabled') ? 'disabled' : ''}>
<slot></slot>
</button>
`;
}
}
customElements.define('ds-button', DsButton);<!-- Works in React, Vue, Angular, Svelte, or plain HTML -->
<ds-button variant="primary" size="lg">Submit</ds-button>
<ds-button variant="danger">Delete</ds-button>Micro-Frontend Isolation
// Each micro-frontend is encapsulated in a shadow DOM
class MicroApp extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
async connectedCallback() {
const src = this.getAttribute('src');
if (!src) return;
try {
const res = await fetch(src);
const html = await res.text();
this.shadowRoot.innerHTML = html;
// Execute scripts within shadow DOM context
this.shadowRoot.querySelectorAll('script').forEach(script => {
const newScript = document.createElement('script');
newScript.textContent = script.textContent;
this.shadowRoot.appendChild(newScript);
});
} catch (e) {
this.shadowRoot.innerHTML = `<p>Failed to load micro-app</p>`;
}
}
disconnectedCallback() {
this.shadowRoot.innerHTML = '';
}
}
customElements.define('micro-app', MicroApp);Form Integration
// Custom form element with FormAssociated API
class StarRating extends HTMLElement {
static formAssociated = true;
constructor() {
super();
this.internals = this.attachInternals();
this.attachShadow({ mode: 'open' });
this._value = 0;
}
connectedCallback() {
this.render();
this.shadowRoot.addEventListener('click', (e) => {
const star = e.target.closest('[data-value]');
if (star) {
this._value = parseInt(star.dataset.value);
this.internals.setFormValue(this._value);
this.render();
}
});
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host { display: inline-flex; gap: 4px; cursor: pointer; }
.star { font-size: 24px; color: #cbd5e0; transition: color 0.15s; }
.star.active { color: #ecc94b; }
</style>
${[1,2,3,4,5].map(i => `
<span class="star ${i <= this._value ? 'active' : ''}" data-value="${i}">★</span>
`).join('')}
`;
}
// Standard form APIs
get value() { return this._value; }
get form() { return this.internals.form; }
get name() { return this.getAttribute('name'); }
get validity() { return this.internals.validity; }
}
customElements.define('star-rating', StarRating);<form>
<star-rating name="rating"></star-rating>
<button type="submit">Submit</button>
</form>When to Use Web Components
| Scenario | Recommendation |
|---|---|
| Shared design system across frameworks | Use Web Components |
| Single-framework app, internal components | Use framework components |
| Third-party widget distribution | Use Web Components |
| Micro-frontend isolation | Use Shadow DOM |
| SSR-heavy application | Use Declarative Shadow DOM or framework |
| Rich interactivity within a framework | Use framework components |
Best Practices
- Use
mode: 'open'for Shadow DOM unless you have a specific reason to hide internals - Prefer CSS custom properties over
::part()for theming — they compose better - Always call
super()first in the constructor - Clean up event listeners and observers in
disconnectedCallback() - Use
observedAttributes+attributeChangedCallbackfor reactive attribute changes - Name custom elements with a namespace prefix (e.g.,
ds-button,app-modal) to avoid collisions