Shadow DOM and Web Components
Every <video> element you see in a browser has a hidden DOM tree inside it — buttons, progress bars, volume controls that the browser generates but that you can’t inspect or style directly. That’s shadow DOM in action. The spec gave web developers the same capability: attach a hidden DOM tree to any element, keeping its internal structure completely isolated from the rest of the page.
This isolation is what makes Web Components viable. Without it, a custom element’s internal markup and styles would be at war with every CSS selector and script on the page that used it. Shadow DOM draws a boundary.
Shadow DOM Terminology
Before getting into the mechanics, the vocabulary:
- Shadow host — the element in the regular DOM that has a shadow DOM attached to it
- Shadow tree — the DOM tree living inside the shadow DOM
- Shadow root — the root node of that shadow tree (the entry point)
- Shadow boundary — the barrier between the shadow DOM and the regular DOM tree
Creating a Shadow DOM
Imperatively with JavaScript
The element.attachShadow() method creates and returns a shadow root:
const host = document.querySelector("#host");
const shadow = host.attachShadow({ mode: "open" });
const span = document.createElement("span");
span.textContent = "I'm inside the shadow DOM";
shadow.appendChild(span);
Once you have the shadow root, you manipulate it like any other DOM node — appendChild, createElement, setAttribute, all work the same way.
Declaratively with HTML
If you want shadow DOM without JavaScript (useful for server-rendered pages), use the <template> element with the shadowrootmode attribute:
<div id="host">
<template shadowrootmode="open">
<span>I'm in the shadow DOM</span>
</template>
</div>
When the browser parses this HTML, it replaces <template shadowrootmode="open"> with a shadow root attached to the parent element. The <template> itself disappears from the DOM — only its content remains, wrapped in a shadow root.
The shadowrootmode attribute accepts open or closed, the same as the JavaScript option.
Mode: Open vs Closed
When calling attachShadow(), you pass { mode: "open" } or { mode: "closed" }.
- Open mode:
hostElement.shadowRootreturns the shadow root object. Any JavaScript running on the page can access the shadow DOM internals through this property. - Closed mode:
hostElement.shadowRootreturnsnull. The shadow DOM is truly hidden.
// open
const shadow = document.querySelector("#host").attachShadow({ mode: "open" });
console.log(document.querySelector("#host").shadowRoot); // ShadowRoot object
// closed
const closedShadow = document.querySelector("#host").attachShadow({ mode: "closed" });
console.log(document.querySelector("#host").shadowRoot); // null
Closed mode is not a security mechanism — browser extensions and certain hacking techniques can still access closed shadow roots. Think of it as a signal to external code: “please don’t touch my internals.” It won’t stop a determined developer.
CSS Encapsulation
This is the main reason to use shadow DOM. Styles defined outside the shadow tree don’t match elements inside it, and styles inside the shadow tree don’t leak out:
<div id="host"></div>
<span>I'm in the regular DOM</span>
<script>
const host = document.querySelector("#host");
const shadow = host.attachShadow({ mode: "open" });
shadow.innerHTML = "<span>I'm in the shadow DOM</span>";
</script>
<style>
span { color: blue; }
</style>
The page’s span { color: blue; } styles only affect the <span> in the regular DOM. The shadow DOM span keeps whatever default styling it has. This means you can use generic selectors like span, div, or .button inside a component without worrying they’ll match or be matched by page styles.
Styling Inside a Shadow DOM
With a <style> element
The classic approach — put a <style> inside the shadow tree:
const template = document.getElementById("my-component").content;
const shadow = document.querySelector("#host").attachShadow({ mode: "open" });
shadow.appendChild(template.cloneNode(true));
<template id="my-component">
<style>
button { background: #007bff; color: white; border: none; padding: 8px 16px; }
button:hover { background: #0056b3; }
</style>
<button>Click me</button>
</template>
With Constructable Stylesheets
Modern browsers support CSSStyleSheet objects that you can create and share across multiple shadow roots:
const sheet = new CSSStyleSheet();
sheet.replaceSync("button { background: #007bff; color: white; }");
const shadow = document.querySelector("#host").attachShadow({ mode: "open" });
shadow.adoptedStyleSheets = [sheet];
The advantage over <style> elements: you create the stylesheet once and assign it to many shadow roots. The browser parses it once. You can also mutate it dynamically and the changes propagate everywhere it’s used:
sheet.replace("button { background: red; }"); // updates all components using this sheet
Shadow DOM and Custom Elements
Custom elements almost always attach their own shadow DOM. Without it, the element’s internal implementation would be visible and manipulable by the page — making it fragile and impossible to maintain.
class FilledCircle extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: "open" });
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("width", "100");
svg.setAttribute("height", "100");
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
circle.setAttribute("cx", "50");
circle.setAttribute("cy", "50");
circle.setAttribute("r", "50");
circle.setAttribute("fill", this.getAttribute("color") ?? "blue");
svg.appendChild(circle);
shadow.appendChild(svg);
}
}
customElements.define("filled-circle", FilledCircle);
Usage:
<filled-circle color="green"></filled-circle>
<filled-circle color="#ff6600"></filled-circle>
The custom element developer controls the internal structure completely. Page CSS can’t reach in and break the SVG layout. Page JS can’t manipulate the circles directly. The component owns its internals.
Slots and Light DOM Content
Shadow DOM can expose <slot> elements that accept content from outside the shadow tree — this is how web components compose with the page.
class MyCard extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: "open" });
shadow.innerHTML = `
<div class="card">
<h2><slot name="title">Default Title</slot></h2>
<p><slot>Default content</slot></p>
</div>
<style>
.card { border: 1px solid #ccc; padding: 16px; border-radius: 8px; }
h2 { margin: 0 0 8px; }
</style>
`;
}
}
customElements.define("my-card", MyCard);
Usage:
<my-card>
<span slot="title">Hello World</span>
<p>This content fills the default slot.</p>
</my-card>
The <slot name="title"> receives the element with slot="title" from outside. The unnamed <slot> receives everything else.
Styling Slotted Content
Use ::slotted() to style elements that have been assigned to a slot, from inside the shadow DOM:
::slotted(h2) { font-size: 1.25rem; color: #333; }
::slotted(p) { color: #666; }
Note that ::slotted() has limited pseudo-selectors available — you can only use element-type and class selectors inside it, not descendant or combinator selectors.
Host-Specific Styles
The :host pseudo-class selects the shadow host element from inside the shadow tree:
:host { display: block; }
:host([hidden]) { display: none; }
:host(.fancy) { border: 2px solid gold; }
:host() lets you conditionalize on the host element’s attributes. :host-context() lets you conditionalize on an ancestor of the host.
Attribute Inheritance
Shadow trees and <slot> elements inherit dir (text direction) and lang attributes from their shadow host. This ensures text direction and language styling flows into the shadow DOM correctly.
Key Takeaways
attachShadow({ mode: "open" })creates an accessible shadow root;mode: "closed"hides it- CSS selectors in the page don’t reach into shadow DOM, and shadow styles don’t leak out
- Use
<template>+shadowrootmodefor declarative shadow DOM creation - Use
adoptedStyleSheetswithCSSStyleSheetfor shareable, mutable styles - Custom elements almost always use shadow DOM — it’s what makes them reliable
- Slots let you compose external content into a component’s shadow tree
See Also
- javascript-import-maps — related web standards for module loading
- browser-service-workers — another encapsulation boundary in the browser
- javascript-prototypes — the prototype chain underlying custom element classes