Shadow DOM: Encapsulated Components Before Frameworks Existed
Long before React components or Scoped CSS existed, browsers introduced Shadow DOM as a way for HTML elements to have their own internal DOM tree with isolated styles. The <video> element's controls, the <input type="range"> slider, the <details> disclosure widget — these all use Shadow DOM under the hood to render their internal structure without exposing or polluting the page's DOM.
Shadow DOM is now part of the Web Components standard and available for use in any JavaScript without a framework.
Shadow Host and Shadow Root
Any HTML element can become a shadow host — a node that has an additional, hidden DOM tree attached to it. The attachment is the shadow root:
const host = document.getElementById('my-component')
const shadow = host.attachShadow({ mode: 'open' })
shadow.innerHTML = `
<style>
p { color: red; }
</style>
<p>I am in the shadow DOM</p>
`
The <p> in the shadow root is separate from the main document. The p { color: red } style applies only within this shadow root — it does not affect any <p> in the rest of the page.
Open vs Closed Mode
The mode option controls accessibility of the shadow root from JavaScript:
// open — accessible via element.shadowRoot
const shadow = host.attachShadow({ mode: 'open' })
console.log(host.shadowRoot) // the shadow root
// closed — not accessible via element.shadowRoot
const shadow = host.attachShadow({ mode: 'closed' })
console.log(host.shadowRoot) // null
Closed mode is used for browser-native elements (like <video>). For custom elements, open is almost always preferred — closed only provides superficial encapsulation since the reference to the shadow root can still be captured when attachShadow is called.
Style Encapsulation
Shadow DOM creates a hard boundary for CSS:
- Styles defined inside a shadow root do not leak out to the main document
- Global styles from the main document do not penetrate into a shadow root
<style>
/* Main document */
p { color: blue; }
</style>
<my-component></my-component>
<script>
const host = document.querySelector('my-component')
const shadow = host.attachShadow({ mode: 'open' })
shadow.innerHTML = `` // blue does NOT apply here
</script>
Exceptions: CSS custom properties (variables) DO pierce the shadow boundary. This is intentional — it is the designed mechanism for theming shadow DOM components from outside:
/* Main document */
my-component {
--text-color: green;
}
// Inside shadow root
shadow.innerHTML = `
<style>
p { color: var(--text-color, black); }
</style>
<p>Themed from outside</p>
`
Inherited properties like color and font-family also inherit into shadow DOM from the host element.
Slots: Projecting Light DOM into Shadow DOM
A key Shadow DOM feature is composition with <slot>. Slots let external content ("light DOM") be projected into specific places within the shadow tree:
shadow.innerHTML = `
<div class="card">
<slot name="title"></slot>
<slot></slot>
</div>
`
<my-card>
<h2 slot="title">Card title</h2>
<p>This goes in the default slot</p>
</my-card>
The h2 appears in the named slot; the p appears in the default slot. The light DOM content is rendered inside the shadow tree without being moved there — it remains in the main document but is displayed as if placed inside the shadow.
Custom Elements + Shadow DOM
Shadow DOM is most powerful when combined with Custom Elements:
class MyButton extends HTMLElement {
constructor() {
super()
const shadow = this.attachShadow({ mode: 'open' })
shadow.innerHTML = `
<style>
button {
background: var(--button-bg, #0070f3);
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
</style>
<button><slot>Click me</slot></button>
`
}
}
customElements.define('my-button', MyButton)
<my-button>Submit</my-button>
<my-button> is now a real HTML element that works in any framework (or no framework), with its styles completely isolated.
Shadow DOM vs React's Encapsulation
React does not use Shadow DOM. React's component model provides logical encapsulation (component boundaries) but not DOM or CSS encapsulation. CSS Modules and Tailwind both work within the regular DOM. Shadow DOM is a platform primitive; React is an abstraction on top of the platform.
React and Shadow DOM can coexist — you can render a React application inside a shadow root, or use Shadow DOM-based Web Components inside a React application. Event handling requires care: React's synthetic event system uses event delegation at the document root, which means events originating in closed shadow roots may not bubble to React's listener.
Conclusion
Shadow DOM is the browser's native answer to the "how do I encapsulate a UI component" question. It predates modern frameworks and is the foundation of web components that work in any context. Its style encapsulation is real and bilateral — styles cannot leak in or out — with CSS custom properties as the intentional theming escape hatch. For platform-agnostic components, Web Components + Shadow DOM is the most future-proof encapsulation approach available.
