Isolate with
Shadow DOM.
Shadow DOM gives your component a private, sealed DOM tree. Styles inside cannot leak out. Styles outside cannot leak in. No conventions needed. No build tools. Just the browser.
This tutorial builds up Shadow DOM knowledge one concept at a time, starting from the problem it solves. Every step has a live demo you can see immediately.
MDN — Using Shadow DOM · Official DocumentationThis tutorial assumes you know the basics of Web Components. If you haven't already, read the Web Components tutorial first.
The Problem
Without Shadow DOM, your component's styles live in the global stylesheet. They can collide with other styles on the page — silently, unpredictably.
/* Global stylesheet */ p { color: red; font-size: 24px; } /* Your component writes a <p> tag */ /* It will be red and 24px — whether you want it or not */ /* You have no control */
← This <p> is styled by the page
- The component above renders a
<p>tag - The page's CSS is bleeding into the component and styling it
- This is the problem Shadow DOM exists to prevent
attachShadow()
attachShadow() creates a separate, private DOM tree
attached to your element. It must be called in the
constructor(), before the element enters the page.
class MyCard extends HTMLElement { constructor() { super() // always first // Create the shadow root // Returns a ShadowRoot object const shadow = this.attachShadow({ mode: "open" }) // 'open' → JS outside can read this.shadowRoot // 'closed'→ this.shadowRoot returns null from outside // (rarely used — adds friction, not real security) } }
- Creates a new, isolated DOM tree inside your element
-
Returns a
ShadowRoot— accessible viathis.shadowRoot - mode: "open" is almost always what you want
- Can only be called once per element — calling twice throws an error
└─ <body>
└─ <my-card> ← your element
└─ #shadow-root ← private tree starts here
└─ <div> ← lives inside the shadow
└─ <p> <img>
Writing to shadowRoot
Instead of writing to this.innerHTML (which puts
content in the regular DOM), write to
this.shadowRoot.innerHTML. Everything inside is now
protected.
class MyCard extends HTMLElement { constructor() { super() this.attachShadow({ mode: "open" }) } connectedCallback() { // ✗ this.innerHTML — writes to the LIGHT DOM // exposed to the page, styles can bleed in // this.innerHTML = '<p>Hello</p>' // ✓ this.shadowRoot.innerHTML — writes to the SHADOW DOM // completely isolated from the page this.shadowRoot.innerHTML = ` <p>Hello from the Shadow!</p> ` } } customElements.define("my-card", MyCard)
← Regular <p> — affected by page CSS
-
The component renders a
<p>inside its Shadow DOM - The page's red CSS does not affect it — it is isolated
- Light DOM = the regular DOM, visible to page styles
- Shadow DOM = the private tree, invisible to page styles
Style Isolation
A <style> tag inside Shadow DOM
only affects that component. It cannot reach
outside. The page cannot reach in. This is true browser-enforced
encapsulation.
class StyledCard extends HTMLElement { constructor() { super() this.attachShadow({ mode: "open" }) } connectedCallback() { this.shadowRoot.innerHTML = ` <style> p { color: #c8f542; font-size: 15px; font-family: monospace; background: #111; padding: 12px 16px; border-radius: 6px; border-left: 2px solid #c8f542; } </style> <p>I am styled by Shadow DOM.</p> <p>These styles cannot escape this component.</p> ` } } customElements.define("styled-card", StyledCard) // Any <p> tags OUTSIDE this component // are completely unaffected by the styles above
-
Both components define a
p { color: ... }rule - Neither rule affects the other — complete isolation
- No BEM class names. No CSS Modules. No build step. Just Shadow DOM.
- CSS leaks between components
- Need BEM / CSS Modules
- Global resets break things
- Order of styles matters
- Unpredictable at scale
- Styles are truly scoped
- No naming conventions needed
- Global CSS cannot interfere
- Order does not matter
- Predictable always
The :host Selector
Inside Shadow DOM, :host refers to the custom element
itself — the <my-card> tag in the page. Use it to
set display, layout, sizing, and other outer styles.
<style> /* :host = the <my-card> element itself */ :host { display: block; /* custom elements are inline by default! */ width: 240px; border: 1px solid #1e1e1e; border-radius: 8px; overflow: hidden; } /* :host() with a selector — apply when condition is true */ :host([theme="dark"]) { background: #0a0a0a; color: #e2e2dc; } :host([theme="light"]) { background: #f5f5f5; color: #111; } /* :host-context() — style based on parent */ :host-context(.sidebar) { width: 100%; /* full width when inside .sidebar */ } </style>
-
Custom elements are inline by default — always
set
display: blockorinline-blockin :host - :host(selector) — conditional styles based on attributes or classes
- Page styles can still override :host — external CSS has higher specificity than :host
Page CSS can override :host styles.
So my-card { display: none } in your stylesheet will
work. Only styles targeting elements inside the shadow
are blocked.
CSS Custom Properties
Shadow DOM blocks regular CSS — but CSS Custom Properties (variables) pierce through. This is the intentional, clean way to let the outside world theme your component.
/* ── Outside the component (page CSS) ── */ themed-button { --btn-color: #c8f542; --btn-background: #111; --btn-radius: 4px; } themed-button.danger { --btn-color: #fff; --btn-background: #cc3333; } /* ── Inside the component (Shadow DOM) ── */ :host { display: inline-block; } button { color: var(--btn-color, #000); /* fallback after comma */ background: var(--btn-background, #c8f542); border-radius: var(--btn-radius, 4px); border: none; padding: 10px 20px; cursor: pointer; font-family: monospace; font-size: 14px; }
- CSS Custom Properties are inherited — they pass through shadow boundaries
-
Regular CSS properties do not —
color: redon the host stops at the boundary -
Fallback values —
var(--my-var, fallback)ensures the component works without theming - This is the official pattern for building themeable Web Components
Slots
A <slot> is a placeholder inside your Shadow DOM
where the user's HTML content is projected. The content stays in the
Light DOM but appears visually inside your component.
// ── Component definition ── class InfoCard extends HTMLElement { constructor() { super() this.attachShadow({ mode: "open" }) } connectedCallback() { this.shadowRoot.innerHTML = ` <style> :host { display: block; border: 1px solid #1e1e1e; border-radius: 8px; overflow: hidden; } header { background: #111; padding: 12px 16px; border-bottom: 1px solid #1e1e1e; } main { padding: 16px; color: #a8a8a2; } footer { padding: 10px 16px; border-top: 1px solid #1e1e1e; font-size: 12px; color: #5a5a54; } </style> <header> <slot name="title">Default Title</slot> </header> <main> <slot></slot> </main> <footer> <slot name="footer">No footer provided</slot> </footer> ` } } customElements.define("info-card", InfoCard) <!-- ── Usage in HTML ── --> <info-card> <span slot="title">My Card Title</span> <p>This is the body content of the card.</p> <span slot="footer">Last updated: today</span> </info-card>
Slots project Light DOM content into the Shadow DOM visually.
Step 07 demoThis card has no footer slot — the default text shows instead.
- <slot> — default slot, catches any unassigned content
-
<slot name="x"> — named slot, only
catches
slot="x"content -
Fallback content — text inside
<slot>shows when nothing is projected - Slotted content stays in the Light DOM — it is projected visually, not moved
-
You can still style slotted content with
::slotted(selector)
Slots are powerful but add complexity to the state model. In Flow-Arch demos, we generally avoid slots and pass all data through attributes and state instead. Slots are best for layout/wrapper components, not data-driven components.
Full Example
A complete component using all Shadow DOM concepts: isolation,
:host, CSS variables for theming, and clean scoped
styles.
class FlowBadge extends HTMLElement { constructor() { super() this.attachShadow({ mode: "open" }) } // Re-render when these attributes change static get observedAttributes() { return ["label", "count"] } attributeChangedCallback() { this.render() } connectedCallback() { this.render() } render() { const label = this.getAttribute("label") || "badge" const count = this.getAttribute("count") || "" this.shadowRoot.innerHTML = ` <style> /* :host controls outer layout */ :host { display: inline-flex; align-items: center; gap: 6px; } /* CSS variables let the page theme this component */ .badge { background: var(--badge-bg, #1e1e1e); color: var(--badge-color, #c8f542); border: 1px solid var(--badge-border, #2a2a2a); padding: 4px 10px; border-radius: 100px; font-family: monospace; font-size: 12px; letter-spacing: 0.05em; } /* Scoped — only affects .count inside THIS component */ .count { background: var(--badge-color, #c8f542); color: var(--badge-bg, #000); padding: 2px 8px; border-radius: 100px; font-family: monospace; font-size: 11px; font-weight: 500; } </style> <span class="badge">${label}</span> ${count ? `<span class="count">${count}</span>` : ""} ` } } customElements.define("flow-badge", FlowBadge)