What Is Shadow DOM? Encapsulated Web Components
Shadow DOM attaches an isolated DOM tree to an element so a component's styles and markup can't leak in or out. Here's how it actually works.
Shadow DOM is a browser API that attaches a separate, encapsulated DOM tree to an element, so the markup and styles inside it are isolated from the rest of the page. Nothing declared inside a shadow tree leaks out, and nothing outside — global CSS, ID collisions, stray selectors — leaks in. It’s the mechanism that makes Web Components actually reusable across unrelated codebases.
The problem it solves
Without Shadow DOM, every piece of HTML you insert into a page shares one global namespace for CSS and DOM. A .card class in your component can be overridden by a .card class defined somewhere else in the app, and a broad selector like div > p in a global stylesheet can style content it was never meant to reach. This is fine for a single application’s own markup, where you control the whole tree, but it breaks down for a reusable component meant to drop into arbitrary pages.
Shadow DOM fixes this by giving an element its own miniature document. Styles defined inside a shadow root don’t escape it, and — with narrow, deliberate exceptions — outside styles don’t reach in either.
Shadow root, host, and boundary
Three terms describe the pieces:
- Shadow host — the regular DOM element that a shadow tree is attached to, e.g.
<user-card>. - Shadow root — the root node of the encapsulated tree, created by calling
element.attachShadow({ mode: "open" }). - Shadow boundary — the conceptual wall between the shadow tree and the rest of the document. CSS specificity and cascade rules stop at this boundary in both directions.
class UserCard extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: "open" });
shadow.innerHTML = `
<style>
p { color: rebeccapurple; }
</style>
<p>Encapsulated content</p>
`;
}
}
customElements.define("user-card", UserCard);
The <p> inside this shadow tree is styled purple by the component’s own <style> tag, regardless of what color the host page assigns to p elsewhere. And the host page’s global stylesheet has no way to accidentally restyle it.
Open vs closed mode
attachShadow takes a mode of "open" or "closed". With "open", the shadow root is accessible from JavaScript via element.shadowRoot — useful for debugging and for library code that needs to inspect its own component. With "closed", element.shadowRoot returns null from the outside; only code that holds a reference from inside attachShadow’s own return value can reach it.
In practice, almost every framework and component library uses "open" mode. "closed" mode makes testing and browser devtools inspection harder without adding meaningful security — anyone can still see the rendered output in the accessibility tree and on screen.
Slots: letting content in deliberately
Full isolation would make components useless for anything that needs to accept child content — a <custom-dialog> needs to render whatever the caller puts inside it. The <slot> element solves this: it’s a designated hole in the shadow tree where light DOM (the host page’s regular markup) gets projected.
<template id="dialog-template">
<div class="dialog">
<slot></slot>
</div>
</template>
Content between the host element’s tags renders wherever <slot> appears inside the shadow tree, styled by the host page’s rules, not the component’s — slotted content stays in the light DOM conceptually even though it displays inside the shadow tree.
Styling across the boundary
A handful of CSS features are designed to cross the shadow boundary deliberately:
- CSS custom properties (CSS variables) inherit through shadow boundaries by default, which is the main sanctioned way to theme a component from outside.
:hostand:host()let the component style its own host element from inside the shadow tree.::part()lets a component author explicitly expose named internal elements (part="button") for external styling, without opening up the whole internal structure.
Everything else — descendant selectors, ID selectors, the cascade in general — stops at the boundary in both directions. This is the entire point: a component author can change internal markup freely without breaking pages that use the component, and a page author can’t accidentally break a component’s internals with a global selector.
Shadow DOM vs the virtual DOM
These two terms sound similar but solve unrelated problems. Shadow DOM is a real, encapsulated portion of the browser’s actual DOM tree, used for style and markup isolation. The virtual DOM is an in-memory representation frameworks like React use to compute efficient updates before touching the real DOM at all — it has nothing to do with encapsulation. A component can use both, neither, or either independently; frameworks such as Svelte that compile away a virtual DOM can still use Shadow DOM for encapsulation if their compiled output targets custom elements.
When to reach for it
Shadow DOM earns its complexity for framework-agnostic component libraries, design-system widgets meant to be embedded in third-party pages, and anything shipped as a standalone <script> tag where you can’t control the host page’s CSS. Inside a single application already using a framework’s own scoping mechanism — CSS modules, CSS layers, or a framework’s scoped styles — Shadow DOM is usually unnecessary overhead; those tools solve the same isolation problem at build time instead of runtime.
The takeaway
Shadow DOM attaches an encapsulated DOM tree to a host element so styles and markup can’t cross the boundary except through deliberate escape hatches — custom properties, :host, and ::part(). It’s the browser-native answer to CSS and DOM collisions, and it’s most valuable when a component has to survive being dropped into a page it doesn’t control. For app-internal components already isolated by a framework’s own tooling, it’s rarely worth the added indirection.
Tagged
Keep reading
Takina · · 4 min read The View Transitions API: Native Animated Page Changes
The View Transitions API brings smooth, app-like transitions to the web without a heavy SPA framework. Here's how it works and how to use it today.
Takina · · 4 min read TypeScript Abstract Classes, Explained
Abstract classes in TypeScript define shared implementation plus methods subclasses must fill in. How they differ from interfaces and when to reach for them.
Takina · · 4 min read What Is a Lockfile? Reproducible Dependency Installs
A lockfile records the exact dependency versions your package manager resolved, so every install — from your laptop to CI — reproduces the same tree.