Learn · Web Components track
All tutorials
Topic tutorial

Shadow DOM — Encapsulation for Web Components

What the shadow root is, why styles don't leak, and how to pierce it when testing.

2026-07-24 topic tutorial ~8 min read

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.

Good to knowSvelte scopes styles at build time; Shadow DOM scopes them at runtime in the browser. Custom elements (e.g. YourSafe components) use Shadow DOM.
Key takeaways
  • 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.
Section understood — nicely done.

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.

Tap a numbered badge in the code to read its note.
user-badge.ts ts
1Open vs. closed mode
mode: "open" exposes element.shadowRoot to outside JavaScript — essential for tests. "closed" hides it and makes testing painful.
2Styles stay contained
This rule lives inside the shadow root. Page-level .name { } rules never reach it, and this rule never leaks out.
3Read attributes when connected
connectedCallback runs when the element is inserted; attributes are readable here, not reliably in the constructor.
Key takeaways
  • attachShadow({ mode: "open" }) creates the boundary and exposes element.shadowRoot to outside scripts.
  • Markup and styles rendered into the root are sealed inside it; read attributes in connectedCallback, not the constructor.
Section understood — nicely done.

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.

1Start with a global <style>1 / 3
The first version injects a <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.
2Scope it with attachShadow2 / 3
Swap the head injection for 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.
3Reach in from a test3 / 3
Because the mode is 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.
user-badge.evolution.ts ts
Key takeaways
  • 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.
Section understood — nicely done.

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.

Watch outPlaywright pierces open shadow roots automatically in CSS selectors, but getByRole visibility rules still apply — a slot without assigned nodes renders nothing.
badge.test.ts ts
const badge = document.querySelector("user-badge");expect(badge.shadowRoot.querySelector(".name")).toHaveTextContent("Martin");
🤔 Check yourself — Why can't a page-level .name selector ever match inside the shadow tree?
Selector matching is scoped per tree. A shadow root is a separate node tree with its own style scope, so a document stylesheet is only ever matched against nodes in the document tree — the shadow tree's nodes simply aren't candidates. It isn't a specificity fight you can out-rank; the inner nodes are invisible to outside selectors entirely. Only ::part(), inherited properties, and CSS custom properties are allowed to cross the boundary.
Key takeaways
  • 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.
Section understood — nicely done.

Quick check

2 questions · answers reveal instantly

Q1.What does mode: "open" control?
Correct. "open" exposes element.shadowRoot to outside scripts; "closed" returns null. CSS never crosses the boundary in either mode, and slots work in both.
Q2.Where do styles defined inside a shadow root apply?
Correct. A shadow root's styles are scoped to that single tree only. They don't touch the page, and each element instance gets its own isolated shadow root.

All four sections understood!

You've completed the Shadow DOM tutorial. Onward to the next topic.