What is shadow DOM in web development
Shadow DOM is a browser API that attaches a separate, encapsulated DOM subtree to a host element, keeping its HTML, CSS, and JS isolated from the rest of the page.
Theory
TL;DR
- Think of it like a soundproof apartment in a busy building: internal styles stay inside, external styles cannot get in.
- Regular DOM mixes all CSS rules globally; Shadow DOM draws a hard boundary at the element level.
- The browser allocates a separate node tree per shadow root; CSS selectors stop at that boundary and do not cross it.
mode: 'open'exposes the shadow root to external JS;mode: 'closed'hides it, returningnullforhost.shadowRoot.- Use it for reusable UI components, third-party embeds, or micro-frontends where style collisions are a real problem.
Quick example
<!DOCTYPE html>
<html>
<head>
<style>p { color: blue !important; }</style>
</head>
<body>
<p>Regular paragraph (blue, affected by page CSS).</p>
<div id="host"></div>
<script>
const shadow = document.getElementById('host').attachShadow({ mode: 'open' });
shadow.innerHTML = ``;
</script>
</body>
</html>The outer !important rule cannot reach inside the shadow root. The red paragraph ignores it completely. That is the whole point.
Key difference
Without Shadow DOM, all CSS competes globally through inheritance and specificity. One badly named .btn class in a third-party widget can override your button styles. Shadow DOM draws a hard boundary: the browser renders a distinct tree where internal styles match only internal elements, and external styles never pierce in. The only bridge is host.shadowRoot, and only when mode is 'open'.
When to use
- Reusable UI widgets (dropdowns, modals, date pickers) that ship with their own styles and must not clash with host page CSS.
- Third-party embeds (chat widgets, payment buttons) that load on unknown host pages with unpredictable stylesheets.
- Micro-frontends where multiple teams share a page. Without Shadow DOM, two teams accidentally defining
.cardwith different margins will conflict silently. I have seen this cause bugs that take hours to trace back to the source. - Skip it for simple static pages or tightly controlled apps where isolation is not needed. Plain DOM has no overhead.
Open vs closed mode
{ mode: 'open' } exposes host.shadowRoot for external JS access. Most custom elements and frameworks use this.
{ mode: 'closed' } makes host.shadowRoot return null, blocking external queries. Chrome DevTools panels use this pattern to prevent page JS from interfering with the DevTools UI.
Closed mode is not a security boundary. Browser devtools can still inspect the tree. It only prevents accidental external access from scripts.
Slots and light DOM projection
A <slot> inside a shadow root acts as a placeholder for the host element's children. Those children (light DOM) get projected there at render time.
<div id="host">
<span>I am light DOM</span>
</div>
<script>
const shadow = document.getElementById('host').attachShadow({ mode: 'open' });
shadow.innerHTML = ``;
</script>The <span> renders green and bold because slotted light DOM inherits shadow styles. Use ::slotted(span) to control that behavior explicitly instead of relying on inheritance.
How the browser handles it
Browsers allocate a separate ShadowRoot node linked to the host element. During rendering, the engine composes light DOM children into shadow <slot> elements but skips cross-boundary style resolution. document.querySelector('p') will not find paragraphs inside a shadow root. External JS access requires mode: 'open' and goes through host.shadowRoot.querySelector('p').
Common mistakes
- Querying shadow contents when mode is
'closed'
const el = document.createElement('div');
document.body.appendChild(el);
el.attachShadow({ mode: 'closed' });
// el.shadowRoot === null
el.shadowRoot.querySelector('p'); // TypeError: Cannot read properties of nullFix: use { mode: 'open' } when external access is needed, or pass DOM references through the component's own API.
- Expecting external CSS to reach inside the shadow root
/* This selector cannot pierce the shadow boundary */
my-element * { color: black !important; }To theme shadow components from outside, use CSS custom properties (they cross the boundary by design) or ::part() on exported parts.
- Forgetting that slot projection applies shadow styles
<my-el><p>Light child</p></my-el>If <my-el> has a <slot>, that <p> renders inside the shadow and picks up shadow CSS. Use ::slotted(p) { color: inherit; } to override that intentionally.
- Assuming nested shadow roots are fully isolated from each other
Inner shadow styles can interact with outer shadow via slot composition. Test rendering explicitly. Nesting shadows does not automatically double the isolation.
Real-world usage
- Lit (Google): every component uses Shadow DOM by default, giving zero CSS conflicts across the component tree.
- Angular Elements: wraps Angular components in Shadow DOM for embedding in non-Angular host pages.
- Chrome DevTools: every panel runs in a closed shadow root so page JS and CSS cannot break the DevTools UI.
- Vaadin: enterprise grid and form components ship consistent styles through Shadow DOM across any host application.
Follow-up questions
Q: What is the difference between open and closed mode?
A: open exposes host.shadowRoot for external JS. closed returns null there. Neither mode prevents browser devtools from inspecting the tree.
Q: How do CSS custom properties work across the shadow boundary?
A: They cross the boundary by design. The host page sets --primary-color: red and shadow styles read it. That is the standard way to theme shadow DOM components without breaking isolation.
Q: How do slots work and what is light DOM projection?
A: A <slot> is a placeholder for the host's children. Unnamed slots take all direct children. Named slots use slot="name" on the child element and <slot name="name"> in the shadow template.
Q: Does Shadow DOM affect accessibility?
A: ARIA on the host propagates correctly. Elements inside the shadow tree need explicit roles and labels. Screen readers traverse the flattened composed tree, not the shadow tree in isolation.
Q: (Senior) How would you expose shadow internals for external theming without opening the full shadow root?
A: Use ::part(). Mark internal elements with part="button" in the shadow template. Host pages style them with my-el::part(button) { background: var(--theme-color); }. This gives explicit, controlled exposure without leaking the full internal structure.
Examples
Style isolation in 10 lines
<!DOCTYPE html>
<html>
<head>
<style>p { color: blue; font-size: 24px; }</style>
</head>
<body>
<p>Regular paragraph (blue, 24px).</p>
<div id="host"></div>
<script>
const shadow = document.getElementById('host').attachShadow({ mode: 'open' });
shadow.innerHTML = ``;
</script>
</body>
</html>Two paragraphs, two completely independent style scopes. No !important tricks. No class name gymnastics.
Todo item as a custom element
class TodoItem extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
.todo { padding: 1rem; border: 1px solid #ccc; border-radius: 4px; }
.done { text-decoration: line-through; opacity: 0.5; }
</style>
<div class="todo">
<input type="checkbox">
<span class="text"></span>
</div>
`;
const text = shadow.querySelector('.text');
const checkbox = shadow.querySelector('input');
checkbox.addEventListener('change', () => {
text.classList.toggle('done', checkbox.checked);
});
}
connectedCallback() {
this.shadowRoot.querySelector('.text').textContent =
this.getAttribute('todo') || 'Empty todo';
}
}
customElements.define('todo-item', TodoItem);<todo-item todo="Buy milk"></todo-item>
<todo-item todo="Write tests"></todo-item>Each <todo-item> runs in its own shadow root. Host page CSS cannot override the strikethrough style. No shared class names, no conflicts between components.
Theming via CSS custom properties and ::part()
class FancyButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>
button {
background: var(--btn-bg, #0070f3); /* falls back to blue */
color: var(--btn-color, white);
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>
<button part="btn"><slot></slot></button>
`;
}
}
customElements.define('fancy-button', FancyButton);Host page theming:
/* CSS custom properties cross the shadow boundary */
fancy-button { --btn-bg: #e53e3e; }
/* ::part() targets exported parts explicitly */
fancy-button::part(btn) { border-radius: 0; }This is how production component libraries handle theming without exposing full shadow internals. The component author decides what is public (part="btn"); everything else stays private.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.