Web Components & Shadow DOM
Every component framework â React, Vue, Angular, Svelte â is an abstraction over what the browser can now do natively. Web Components aren't a replacement for these frameworks, but they fill a gap nothing else can: framework-agnostic, encapsulated, standards-based UI primitives that work everywhere, forever.
Custom Elements API
Custom elements let you define new HTML tags with their own behavior:
class UserCard extends HTMLElement {
static observedAttributes = ['name', 'role'];
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
}
disconnectedCallback() {
// Cleanup: remove event listeners, cancel timers, disconnect observers
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this.render();
}
}
adoptedCallback() {
// Called when element is moved to a new document (e.g., iframe adoption)
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host { display: block; padding: 1rem; border: 1px solid #e2e8f0; }
:host([highlighted]) { border-color: #3b82f6; }
.name { font-weight: bold; font-size: 1.125rem; }
.role { color: #64748b; }
</style>
<div class="name">${this.getAttribute('name') || 'Unknown'}</div>
<div class="role">${this.getAttribute('role') || 'Member'}</div>
<slot></slot>
`;
}
}
customElements.define('user-card', UserCard);<user-card name="Sarah Chen" role="Staff Engineer">
<p>Frontend platform team</p>
</user-card>Lifecycle Callbacks
| Callback | When It Fires | Common Use |
|---|---|---|
constructor() | Element created (not yet in DOM) | Setup shadow root, initial state, event bindings |
connectedCallback() | Element added to DOM | Render, fetch data, start observers |
disconnectedCallback() | Element removed from DOM | Cleanup listeners, cancel fetches, disconnect observers |
attributeChangedCallback(name, old, new) | Observed attribute changes | Re-render, update state |
adoptedCallback() | Element moved between documents | Rare; re-initialize document-specific logic |
Critical detail: Only attributes listed in static observedAttributes trigger attributeChangedCallback. This is a deliberate performance optimization â the browser doesn't watch all attributes.
Registration Rules
customElements.define('user-card', UserCard);
// Names MUST contain a hyphen (distinguishes from native elements)
customElements.define('card', Card); // â Error: no hyphen
customElements.define('user-card', Card); // â
Valid
// Check if already defined
customElements.get('user-card'); // Returns constructor or undefined
// Wait for definition
await customElements.whenDefined('user-card');Shadow DOM: True Encapsulation
Shadow DOM creates an encapsulated DOM subtree. Styles don't leak in or out:
class StyledButton extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
button { background: #3b82f6; color: white; border: none;
padding: 0.5rem 1rem; border-radius: 0.375rem; cursor: pointer; }
button:hover { background: #2563eb; }
</style>
<button><slot></slot></button>
`;
}
}
customElements.define('styled-button', StyledButton);<style>button { background: red; }</style> <!-- Global styles -->
<styled-button>Click Me</styled-button> <!-- Still blue â shadow DOM isolates -->
<button>Regular Button</button> <!-- Red from global styles -->Shadow DOM Modes
this.attachShadow({ mode: 'open' });
// element.shadowRoot is accessible from outside
this.attachShadow({ mode: 'closed' });
// element.shadowRoot returns null â truly private
// Used by browser internals (video controls, date picker)Practical advice: Almost always use open. Closed mode prevents devtools inspection and testing â the security benefit is minimal since JavaScript can still access internals through other means.
Slots: Composition in Shadow DOM
Slots allow consumers to inject content into your component:
class AppModal extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); }
.modal { background: white; border-radius: 8px; padding: 2rem;
max-width: 500px; margin: 10vh auto; }
</style>
<div class="overlay">
<div class="modal" role="dialog" aria-modal="true">
<slot name="title"><h2>Dialog</h2></slot>
<slot></slot>
<slot name="actions"></slot>
</div>
</div>
`;
}
}
customElements.define('app-modal', AppModal);<app-modal>
<h2 slot="title">Confirm Delete</h2>
<p>This action cannot be undone.</p>
<div slot="actions">
<button>Cancel</button>
<button>Delete</button>
</div>
</app-modal>::slotted â Styling Slotted Content
The ::slotted() pseudo-element styles content projected through slots, but only at the top level:
/* Inside shadow DOM styles */
::slotted(h2) {
color: #1e293b;
margin: 0;
}
::slotted(p) {
color: #64748b;
}
/* â Does NOT work â ::slotted only targets direct children */
::slotted(div > p) {
color: red;
}::part â Exposing Styling Hooks
Component authors expose styling hooks via part; consumers use ::part() to style them:
<!-- Inside shadow DOM -->
<button part="trigger"><slot></slot></button>/* Consumer stylesheet â outside shadow DOM */
styled-button::part(trigger) { background: #10b981; font-weight: bold; }
styled-button::part(trigger):hover { background: #059669; }This is the controlled styling API â no accidental leaking.
CSS Custom Properties Cross Shadow Boundaries
CSS custom properties (variables) are the one styling mechanism that penetrates shadow DOM:
/* Host page defines tokens */
:root { --brand-color: #3b82f6; --border-radius: 0.5rem; }
/* Inside shadow DOM â inherited custom properties work */
button { background: var(--brand-color, #6366f1); border-radius: var(--border-radius, 0.25rem); }This makes CSS custom properties the theming API for Web Components â define a design token system once, and all shadow DOMs inherit it.
HTML Templates
<template> defines inert HTML â parsed but not rendered until cloned. No images load, no scripts execute:
<template id="card-template">
<div class="card">
<h3 class="title"></h3>
<p class="description"></p>
</div>
</template>const template = document.getElementById('card-template');
const clone = template.content.cloneNode(true);
clone.querySelector('.title').textContent = 'Web Components';
document.body.appendChild(clone);Declarative Shadow DOM
Server-side rendering Web Components requires declarative shadow DOM:
<user-card>
<template shadowrootmode="open">
<style>
:host { display: block; }
.name { font-weight: bold; }
</style>
<div class="name">Sarah Chen</div>
<slot></slot>
</template>
<p>Frontend platform team</p>
</user-card>The browser attaches the shadow root during HTML parsing â no JavaScript needed for the initial render. The custom element's JavaScript can then hydrate and add interactivity.
Shadow DOM & Accessibility
Shadow DOM respects the accessibility tree â both slotted and shadow content are visible to assistive technology.
Gotchas:
aria-labelledbyandaria-describedbycannot reference IDs across shadow boundaries- Use
aria-labelinstead, or ensure referenced IDs are in the same shadow root - Focus delegation:
this.attachShadow({ mode: 'open', delegatesFocus: true })automatically focuses the first focusable element in the shadow root
Web Components vs Frameworks
| Aspect | Web Components | React/Vue/Angular |
|---|---|---|
| Standard | W3C spec, no build step | Library/framework-specific |
| Encapsulation | Real (shadow DOM) | Simulated (CSS modules, scoped) |
| Reactivity | Manual (attributeChangedCallback) | Built-in (virtual DOM, signals, proxies) |
| SSR | Declarative shadow DOM | Framework SSR solutions |
| Ecosystem | Growing | Massive |
| State management | DIY or Lit | Redux, Vuex, signals, etc. |
| Learning curve | DOM APIs directly | Framework abstractions |
| Best for | Design systems, micro-frontends, framework-agnostic widgets | Application development |
When to Use Web Components
- Design systems â Components that must work across React, Vue, Angular, and vanilla JS projects
- Micro-frontends â Isolated widgets from different teams/stacks
- Third-party embeds â Widgets that must not be affected by host page styles
- Progressive enhancement â Custom elements that enhance native HTML
Lit Framework Overview
Lit (~5KB) is the go-to library for building Web Components with ergonomic reactivity:
import { LitElement, html, css } from 'lit';
class UserCard extends LitElement {
static properties = {
name: { type: String },
expanded: { type: Boolean, reflect: true },
};
static styles = css`
:host { display: block; padding: 1rem; border: 1px solid #e2e8f0; }
.details { display: none; }
:host([expanded]) .details { display: block; }
`;
render() {
return html`
<div class="name">${this.name}</div>
<button @click=${() => this.expanded = !this.expanded}>
${this.expanded ? 'Hide' : 'Show'} Details
</button>
<div class="details"><slot></slot></div>
`;
}
}
customElements.define('user-card', UserCard);Lit adds reactive properties (auto-re-render on change), efficient tagged template rendering, and decorators â while staying close to the platform.
Interview Mental Model
Web Components Stack
âââ Custom Elements â Define new HTML tags (customElements.define)
âââ Shadow DOM â Style and DOM encapsulation (attachShadow)
âââ HTML Templates â Inert, reusable markup (<template>)
âââ Slots â Composition / content projection (<slot>)
âââ CSS Parts â Controlled external styling (::part)
When to reach for Web Components:
"Does this need to work in ANY framework or NO framework?"
â Yes: Web Components
â No: Use your framework's component model