Shadow DOM — Encapsulation for Web Components
What the shadow root is, why styles don't leak, and how to pierce it when testing.
01What problem does Shadow DOM solve?
Global CSS means every rule can affect every element. Component libraries historically fought this with naming conventions like BEM or with build-time scoping — clever, but still fundamentally a workaround layered on top of one shared global namespace.
Shadow DOM is the browser-native answer: a subtree with its own DOM scope and stylesheet, invisible to outside selectors. Styles inside stay inside; styles outside can't reach in. Encapsulation becomes a platform feature rather than a discipline you have to enforce by hand.
- Global CSS shares one namespace; scoping used to mean discipline — BEM, build-time tooling, or careful naming.
- Shadow DOM makes encapsulation a browser-native, runtime guarantee: styles inside stay in, styles outside can't reach in.
02Attaching a shadow root
You attach a shadow root inside the element's constructor, then render markup and scoped styles into it. The three badges below explain the load-bearing lines.
mode: "open" exposes element.shadowRoot to outside JavaScript — essential for tests. "closed" hides it and makes testing painful..name { } rules never reach it, and this rule never leaks out.connectedCallback runs when the element is inserted; attributes are readable here, not reliably in the constructor.attachShadow({ mode: "open" })creates the boundary and exposeselement.shadowRootto outside scripts.- Markup and styles rendered into the root are sealed inside it; read attributes in
connectedCallback, not the constructor.
03Walkthrough: from global styles to shadow scope
Click through three stages — as implemented in mr241-test-review.html — as the same custom element moves from a leaky global stylesheet to a fully scoped shadow root. Select a step below to highlight its lines in the code panel.
<style> straight into document.head. It renders fine — but that .name rule now bleeds onto every .name anywhere on the page. Classic global-CSS collision.attachShadow({ mode: "open" }) and move the markup and styles inside. The exact same .name rule is now sealed in the shadow tree — nothing leaks in, nothing leaks out.open, a test hops the boundary through badge.shadowRoot and queries the inner node — or a slot. Closed mode would return null here and the assertion would find nothing.
- One CSS rule, three homes: global head (leaks everywhere) → shadow root (sealed) → a test that reaches in via
shadowRoot. mode: "open"is precisely what makes that final test step possible.
04Piercing the boundary in tests
Testing-library queries don't cross shadow boundaries; you must query through element.shadowRoot or use utilities that do it for you. If you forget, your assertions quietly find nothing and fail in confusing ways.
getByRole visibility rules still apply — a slot without assigned nodes renders nothing.
const badge = document.querySelector("user-badge");expect(badge.shadowRoot.querySelector(".name")).toHaveTextContent("Martin");
.name selector ever match inside the shadow tree?
::part(), inherited properties, and CSS custom properties are allowed to cross the boundary.
- Testing-library queries don't cross the boundary — reach through
element.shadowRoot(or a piercing utility). - Forget it and assertions silently find nothing, failing in confusing ways rather than loudly.
References & further reading
Docs to go deeper, plus where this lives in your codebase
Quick check
2 questions · answers reveal instantly
mode: "open" control?"open" exposes element.shadowRoot to outside scripts; "closed" returns null. CSS never crosses the boundary in either mode, and slots work in both.All four sections understood!
You've completed the Shadow DOM tutorial. Onward to the next topic.