Shadow Boundary
Why external CSS cannot reach inside a web component, what the shadow DOM boundary blocks, what little still passes through, and how to read the boundary in DevTools.
I hit the shadow DOM styling boundary for the first time while working through existing web components. The component looked correct. My selector looked correct. The rule did nothing.
Not "not specific enough." Not being overridden. Just not reaching the element at all.
This is not a bug. It is encapsulation working as intended -- and it will keep feeling broken until you understand what it is doing and why.
The Rule
One line covers most of what you need to know:
If you didn't write it, you can't style it.
"Wrote it" has a specific meaning. The HTML you author between a component's opening and closing tags is the light DOM. It belongs to your document. Your stylesheet reaches it. The HTML the component injects into its shadow root is not yours. Even though you can see it in DevTools, your CSS rules cannot touch it.
/* Works -- you wrote this span */
user-badge span { color: rgb(250, 199, 117); }
/* Does not work -- the component wrote .avatar-circle */
user-badge .avatar-circle { border-radius: 50%; }The second rule is not fighting a specificity battle. It is not being overridden. It is scoped out entirely.
Classes Are Blocked Too
The next thing CSS developers try is class names. Classes should be global, right?
They are not. A class name defined in your stylesheet cannot style a shadow DOM element, even if the component put that exact class name on the element.
/* Your stylesheet */
.submit-btn { background: rgb(60, 52, 137); }<!-- Component's shadow root -->
<button class="submit-btn">Submit</button>The component added the class name, not you, so the element is still not yours to style. The same rule applies: you did not write it.
This matters for utility-class approaches. Tailwind classes applied from your stylesheet cannot reach shadow DOM internals.
There is a workaround if you author the component: link the stylesheet from inside the shadow root using a <link> tag in the component's HTML template.
<!-- Inside the shadow root template -->
<link rel="stylesheet" href="/path/to/tailwind.css">
<button class="px-4 py-2 bg-blue-600">Submit</button>If the browser has already cached that stylesheet from the main page, the second request costs nothing. The classes then apply inside the shadow root because the stylesheet is now scoped inside it. It is not elegant, but it works and has been a valid technique since the beginning of web components.
Why the Boundary Runs Both Directions
The isolation is bidirectional: styles inside the component stay inside, styles from the page stay outside.
The "inside stays inside" direction is immediately useful. A component can define button { background: navy } in its shadow root without that rule affecting every other button on the page. The component is self-contained and portable.
The "outside stays outside" direction is the frustrating one, but it protects something real.
A third-party analytics script on your page might define CSS that reaches into component internals. A design system's base stylesheet can be more aggressive than expected.
The boundary ensures that nothing on the page accidentally restyled something it was never supposed to touch.
Encapsulation only makes sense if it runs both ways.
The >>> Deep Selector That Was Rejected
There was a proposal early in the web components specification for a deep combinator -- >>> -- that would pierce the shadow boundary:
/* Proposed. Never shipped. Does not work today. */
user-badge >>> .avatar-circle { border-radius: 50%; }The proposal was rejected. If styles can pierce the boundary arbitrarily, questions multiply: does the cascade apply one level deep, or infinitely? What happens when shadow roots nest inside shadow roots? At what point does encapsulation mean anything?
The decision was to keep the boundary real and unconditional, then build deliberate styling APIs on top of it. Those APIs -- CSS custom properties and ::part() -- are covered in the next post.
What You Can See in DevTools
Open the Elements panel on any page using web components. Inside a custom element, there is a #shadow-root node. That is the boundary.
Elements below #shadow-root are in the shadow DOM -- your external stylesheet cannot reach them. Elements inside the custom element's tags but outside #shadow-root are light DOM -- your stylesheet can reach them.
This maps directly to the open vs closed shadow root modes discussed when the spec was introduced.
ExpandThe shadow DOM boundary: what CSS reaches and what it doesn't
#shadow-root is the line. Before writing a CSS rule for a component, check which side of that line the target element is on.
What Still Passes Through
Two categories cross the boundary without a special API:
Inheritable properties -- color, font-family, font-size, line-height, and most other text-related properties propagate into shadow DOM. If you set font-family on body, components nested anywhere on the page inherit it.
The catch: form elements and buttons rendered inside shadow DOM often use system UI font defaults. font-family: inherit inside :host is usually needed to pull the page font in reliably. This is a common surprise.
CSS custom properties -- variables pass through the shadow boundary. This is intentional and forms the primary styling API for web components. It deserves its own explanation.
Everything else -- class selectors, element selectors, ID selectors, combinators, attribute selectors -- is blocked at the boundary.
The Essentials
- "If you didn't write it, you can't style it." Light DOM (content between your custom element's tags) is yours to style. Shadow DOM (HTML the component injects) is not.
- This applies to class names too. A class defined in your stylesheet will not style a shadow DOM element carrying that class, even if the name matches exactly.
- The boundary runs both directions: component styles stay inside, page styles stay outside. Both directions serve a purpose.
- The
>>>deep combinator was proposed and rejected. No CSS syntax arbitrarily pierces shadow DOM today. #shadow-rootin the Elements panel marks the boundary. Check which side of it an element falls on before writing a selector.- Inheritable CSS properties (color, font-family) pass through. CSS custom properties pass through. Standard selectors do not.
Further Reading and Watching
- Styling Web Components with Shadow DOM: CSS variables, parts, shared styles -- thorough walkthrough of what crosses the boundary and what does not
- CSS ::part() -- MDN -- reference for the deliberate styling API built on top of the boundary
Practice what you just read.
Keep reading