Understanding Shadow DOM: The Key to True DOM Encapsulation

I seek out simple and scalable solutions to various challenges with tech
Search for a command to run...

I seek out simple and scalable solutions to various challenges with tech
No comments yet. Be the first to comment.
Program to interfaces, not implementations.

ChatGPT is great for brainstorming, but professionals need structured drafts, consistent tone, and version history. Learn when to use ChatGPT vs a document-first AI writing assistant like WriterOS
Today I’m launching ProMind Writer, a focused product built around one simple reality: a huge number of professionals do not “write occasionally.” They write constantly. Proposals, SOPs, policies, PRD

How Every Layer You Add Changes Everything

Many SaaS companies face a common challenge: despite feature-rich products and aggressive marketing, they struggle with high churn rates. The root cause? A classic case of "feedback blindness" – building in isolation without truly understanding user ...

Building modern web applications often means dealing with complex component interactions. Ever noticed how your CSS styles leak into other components, or how global styles override your carefully crafted component styles? Shadow DOM solves these problems.
Think of Shadow DOM as a protective bubble around your component. It keeps your component's code isolated from the rest of the page, just like a bank vault keeps valuables secure from the outside world.
Shadow DOM creates a separate, encapsulated DOM tree attached to an element. This tree is isolated from the main document DOM, giving you true encapsulation for your components.
Here's a simple example:
// Create a custom element
class UserCard extends HTMLElement {
constructor() {
super();
// Create shadow root
const shadow = this.attachShadow({ mode: 'open' });
// Add content
shadow.innerHTML = `
<style>
.card {
border: 1px solid #ccc;
padding: 16px;
margin: 10px;
}
/* These styles won't leak out */
h2 {
color: #2a2a2a;
margin: 0;
}
</style>
<div class="card">
<h2><slot name="username"></slot></h2>
<slot name="details"></slot>
</div>
`;
}
}
// Register the custom element
customElements.define('user-card', UserCard);
Usage:
<user-card>
<span slot="username">John Doe</span>
<div slot="details">Software Engineer</div>
</user-card>
The regular DOM node that the shadow DOM is attached to. In our example above, the <user-card> element is the shadow host.
The root node of the shadow DOM tree. It's created using element.attachShadow() and defines the boundary between the shadow DOM and the regular DOM.
The boundary that keeps shadow DOM internal elements from being accessed from the regular DOM. It's what provides the encapsulation.
Slots are placeholders in your shadow DOM that are filled with content from the light DOM. They create a composable interface for your components:
// Shadow DOM template with slots
const template = `
<div class="wrapper">
<header>
<slot name="header">Default Header</slot>
</header>
<main>
<slot>Default content</slot>
</main>
<footer>
<slot name="footer">Default Footer</slot>
</footer>
</div>
`;
Light DOM: The regular DOM elements that users write. It's what you'd write in your HTML file.
Shadow DOM: The hidden DOM tree attached to a shadow host.
Here's how they interact:
<!-- Light DOM -->
<custom-dialog>
<h1 slot="title">Settings</h1>
<p slot="content">Choose your preferences</p>
</custom-dialog>
<!-- Shadow DOM (internal) -->
<div class="wrapper">
<header>
<slot name="title"></slot>
</header>
<section>
<slot name="content"></slot>
</section>
</div>
Shadow DOM excels in building reusable components. Here's a practical example of a tooltip component:
class TooltipElement extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
.tooltip {
position: relative;
display: inline-block;
}
.tooltip-content {
visibility: hidden;
background-color: #333;
color: white;
padding: 5px;
border-radius: 4px;
position: absolute;
z-index: 1;
bottom: 125%;
left: 50%;
transform: translateX(-50%);
}
:host(:hover) .tooltip-content {
visibility: visible;
}
</style>
<div class="tooltip">
<slot></slot>
<div class="tooltip-content">
<slot name="content"></slot>
</div>
</div>
`;
}
}
customElements.define('custom-tooltip', TooltipElement);
Usage:
<custom-tooltip>
Hover me
<span slot="content">This is the tooltip content!</span>
</custom-tooltip>
Perfect for third-party widgets that need to work anywhere without interference:
class FeedbackWidget extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host {
--primary-color: #007bff;
}
.widget {
position: fixed;
bottom: 20px;
right: 20px;
background: white;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
border-radius: 8px;
padding: 16px;
width: 300px;
}
button {
background: var(--primary-color);
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
</style>
<div class="widget">
<form id="feedback-form">
<h3><slot name="title">Feedback</slot></h3>
<textarea id="feedback-text"></textarea>
<button type="submit">Send</button>
</form>
</div>
`;
this.#attachEventListeners();
}
#attachEventListeners() {
const form = this.shadowRoot.getElementById('feedback-form');
form.addEventListener('submit', (e) => {
e.preventDefault();
const feedback = this.shadowRoot.getElementById('feedback-text').value;
this.dispatchEvent(new CustomEvent('feedback-submitted', {
detail: { feedback }
}));
});
}
}
customElements.define('feedback-widget', FeedbackWidget);
Here's a more advanced React integration that handles state and events:
const useShadowRoot = (initialContent) => {
const hostRef = useRef(null);
const [shadowRoot, setShadowRoot] = useState(null);
useEffect(() => {
if (hostRef.current && !shadowRoot) {
const root = hostRef.current.attachShadow({ mode: 'open' });
setShadowRoot(root);
// Add initial content
if (initialContent) {
root.innerHTML = initialContent;
}
}
}, []);
return [hostRef, shadowRoot];
};
const ShadowComponent = ({ children, styles }) => {
const [hostRef, shadowRoot] = useShadowRoot(`
<style>${styles}</style>
<div id="root"></div>
`);
useEffect(() => {
if (shadowRoot) {
const root = shadowRoot.getElementById('root');
ReactDOM.render(children, root);
// Cleanup on unmount
return () => ReactDOM.unmountComponentAtNode(root);
}
}, [shadowRoot, children]);
return <div ref={hostRef} />;
};
// Usage
const App = () => {
const [count, setCount] = useState(0);
return (
<ShadowComponent styles={`
button {
background: #007bff;
color: white;
border: none;
padding: 8px 16px;
}
`}>
<div>
<h2>Count: {count}</h2>
<button onClick={() => setCount(c => c + 1)}>
Increment
</button>
</div>
</ShadowComponent>
);
};
Style management in Shadow DOM requires careful consideration to avoid performance issues and memory bloat. There are two main approaches:
Individual Styles (Not Recommended)
When you include styles directly in each shadow root, you create duplicate style objects for every instance of your component. This approach wastes memory and processing power.
Shared Styles (Recommended)
Using adoptedStyleSheets, you can share a single stylesheet across multiple shadow roots. This dramatically reduces memory usage and improves performance, especially when you have many component instances.
// BAD: Duplicating styles for each instance
class BadWidget extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>/* Duplicated styles */</style>
<div>Content</div>
`;
}
}
// GOOD: Share styles across instances
const sharedStyles = new CSSStyleSheet();
sharedStyles.replaceSync(`
.widget {
background: white;
padding: 16px;
}
`);
class GoodWidget extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.adoptedStyleSheets = [sharedStyles];
shadow.innerHTML = '<div class="widget">Content</div>';
}
}
Event handling can significantly impact performance when dealing with many interactive elements. Instead of attaching event listeners to individual elements, use event delegation:
Attach a single listener to a parent element
Use event bubbling to handle child events
Reduce memory usage and improve performance
Automatically handle dynamically added elements
class OptimizedList extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
.item { cursor: pointer; }
</style>
<ul id="list"></ul>
`;
// Single event listener for all items
shadow.getElementById('list').addEventListener('click', (e) => {
const item = e.target.closest('.item');
if (item) {
this.handleItemClick(item);
}
});
}
handleItemClick(item) {
// Handle item click
}
}
Proper cleanup is crucial to prevent memory leaks. Always:
Track resources (observers, listeners, timers)
Clean up when the component is removed
Remove event listeners
Disconnect observers
Clear any intervals or timeouts
Here's a practical example:
class CleanupWidget extends HTMLElement {
#observers = new Set();
#listeners = new Set();
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
// Store cleanup references
const observer = new ResizeObserver(this.#handleResize);
observer.observe(this);
this.#observers.add(observer);
const listener = this.#handleClick.bind(this);
shadow.addEventListener('click', listener);
this.#listeners.add({ target: shadow, type: 'click', listener });
}
disconnectedCallback() {
// Clean up observers
for (const observer of this.#observers) {
observer.disconnect();
}
// Clean up event listeners
for (const { target, type, listener } of this.#listeners) {
target.removeEventListener(type, listener);
}
}
}
When working with slots:
Use named slots for clear content distribution
Provide fallback content for empty slots
Style slotted content carefully to maintain encapsulation
Monitor slot changes when needed
class SlotWidget extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<div class="wrapper">
<slot name="header">Default Header</slot>
<slot>Default Content</slot>
</div>
`;
// Monitor slot changes if needed
shadow.querySelector('slot').addEventListener('slotchange', (e) => {
this.handleSlotChange(e);
});
}
}
Problem: Global styles with !important or high specificity can break component styling.
Solution:
Use :host selector strategically
Implement CSS custom properties for customization
Keep component styles modular
class WeatherWidget extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host {
/* Define customizable properties */
--widget-background: #ffffff;
--widget-text: #000000;
display: block;
}
.weather-card {
/* Use custom properties */
background: var(--widget-background);
color: var(--widget-text);
padding: 16px;
}
</style>
<div class="weather-card">
<slot></slot>
</div>
`;
}
}
Problem: Events from shadow DOM are retargeted, making it hard to identify original target.
Solution:
Use composedPath() to access original target
Handle events at appropriate boundaries
Implement custom events when needed
class ClickTracker extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<button>Click Me</button>
`;
shadow.querySelector('button').addEventListener('click', (e) => {
// Get the original target
const originalTarget = e.composedPath()[0];
// Dispatch custom event with additional data
this.dispatchEvent(new CustomEvent('button-clicked', {
bubbles: true,
composed: true,
detail: { originalTarget }
}));
});
}
}
Problem: Difficulty styling and managing slotted content.
Solution:
Use ::slotted() selector carefully
Implement slot change observers
Provide clear content distribution API
class ContentManager extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
/* Style the slot container */
.content-wrapper {
padding: 16px;
}
/* Style specific slotted elements */
::slotted(h1) {
margin-top: 0;
color: var(--header-color, blue);
}
/* Style all slotted content */
::slotted(*) {
font-family: var(--content-font, Arial);
}
</style>
<div class="content-wrapper">
<slot name="header"></slot>
<slot></slot>
</div>
`;
// Monitor slot content changes
shadow.querySelector('slot').addEventListener('slotchange', (e) => {
this.validateContent();
});
}
validateContent() {
const slots = this.shadowRoot.querySelectorAll('slot');
slots.forEach(slot => {
const elements = slot.assignedElements();
// Validate and handle content
});
}
}
Problem: Shadow DOM boundaries can break form submission and validation.
Solution:
Use formAssociated for custom elements
Implement form controls properly
Handle form data explicitly
class CustomInput extends HTMLElement {
static formAssociated = true;
constructor() {
super();
this.internals = this.attachInternals();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<input type="text">
`;
this.input = shadow.querySelector('input');
this.input.addEventListener('input', (e) => {
this.internals.setFormValue(e.target.value);
});
}
// Form control API
get value() {
return this.input.value;
}
set value(val) {
this.input.value = val;
this.internals.setFormValue(val);
}
}
customElements.define('custom-input', CustomInput);
There are three main approaches to pass styles from Light DOM to Shadow DOM:
Most flexible and recommended approach:
class StylableWidget extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
.widget {
/* Define defaults and accept custom properties */
background: var(--widget-bg, #ffffff);
color: var(--widget-color, #000000);
font-size: var(--widget-font-size, 16px);
padding: var(--widget-padding, 1rem);
border-radius: var(--widget-radius, 4px);
}
</style>
<div class="widget">
<slot></slot>
</div>
`;
}
}
customElements.define('stylable-widget', StylableWidget);
Usage in Light DOM:
<style>
stylable-widget {
--widget-bg: #f0f0f0;
--widget-color: #333;
--widget-font-size: 18px;
}
/* Context-specific styling */
.dark-theme stylable-widget {
--widget-bg: #333;
--widget-color: #fff;
}
</style>
<stylable-widget>Content here</stylable-widget>
Useful for reusable styles across multiple shadow roots:
// Create shared stylesheet
const sharedStyles = new CSSStyleSheet();
sharedStyles.replaceSync(`
.widget-base {
padding: 1rem;
margin: 1rem;
border-radius: 4px;
}
`);
class SharedStyleWidget extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
// Adopt shared styles
shadow.adoptedStyleSheets = [sharedStyles];
// Add component-specific styles
const componentStyles = new CSSStyleSheet();
componentStyles.replaceSync(`
.widget {
background: var(--widget-bg, #fff);
}
`);
shadow.adoptedStyleSheets = [...shadow.adoptedStyleSheets, componentStyles];
shadow.innerHTML = `
<div class="widget-base widget">
<slot></slot>
</div>
`;
}
}
Useful for runtime style updates:
class DynamicStyledWidget extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
this.styleElement = document.createElement('style');
shadow.appendChild(this.styleElement);
shadow.innerHTML += `
<div class="dynamic-widget">
<slot></slot>
</div>
`;
}
// Attribute change observer
static get observedAttributes() {
return ['theme'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'theme') {
this.updateStyles(newValue);
}
}
updateStyles(theme) {
const styles = {
light: `
.dynamic-widget {
background: #fff;
color: #000;
}
`,
dark: `
.dynamic-widget {
background: #333;
color: #fff;
}
`
};
this.styleElement.textContent = styles[theme] || styles.light;
}
}
Usage:
<dynamic-styled-widget theme="dark">
Content with dynamic styling
</dynamic-styled-widget>