skip to content

Styling Across the Shadow Boundary

You will learn the sanctioned holes in style encapsulation — custom properties, ::part, ::slotted — and why they exist. Interviewers ask this as the practical objection: 'if nothing gets in, how do I theme it?'.

on this pageshow

questions

6

A custom element attaches a shadow root and renders <span class="label">Hi</span> inside it. Why does the page's `body { font-family: Georgia }` change that text, while the page's `body .label { color: red }` does nothing?

level: juniorimportance: must knowfreq 55%

answer

  1. two mechanisms, not one
  2. selectors stop, values still flow
  3. the inherited-property flag decides
  4. host element is the doorway
  5. resets do not reach inside

basics

~20 s

Inheritance crosses a shadow boundary; selector matching does not. font-family is an inherited property, so its computed value flows from the host down into the shadow tree. A page selector can never match a node inside another tree's shadow root.

solid answer

~50 s

Two separate mechanisms are at play. **Selector matching is scoped per tree**: a rule in the document stylesheet only matches nodes in the document tree, so `body .label` cannot see a `.label` that lives inside a shadow root, and the same is true in reverse. **Inheritance is computed over the flattened tree**, and the shadow tree's top-level nodes inherit from the host element. So every *inherited* property — `color`, `font-family`, `line-height`, `letter-spacing`, `visibility`, `cursor`, `list-style` — keeps flowing inward, while non-inherited ones like `background`, `border`, `padding` or `box-sizing` stop at the host. Open vs closed mode changes nothing about CSS; mode only affects JavaScript access to the root. That is why a global reset like `* { box-sizing: border-box }` silently fails to apply inside components, and why themeable hooks have to be built deliberately — with custom properties or `part` names.

go deeper

for a junior

Be able to say plainly that inherited properties like color and font-family reach into a shadow root while page selectors do not match anything inside it. Naming a couple of properties on each side is enough here.

for a middle

Explain the mechanism: selector matching is per tree, inheritance is computed over the flattened tree where the host is the parent. Mention that a universal-selector reset therefore never applies inside a shadow root.

for a senior

Show the production consequence — components that inherit page typography by design, and components that must reset at :host to survive hostile host pages. Be ready to weigh all: initial against re-declaring a few properties.

for a principal

Own the library-wide stance: does your component set inherit the host page's typography (cheap integration, unpredictable rendering) or reset at the boundary (consistent, but every consumer theme now needs explicit hooks)? Say which you would pick and what it costs consumers.

## Two mechanisms that people collapse into one "Shadow DOM encapsulates styles" is true, but it describes only half of what happens. There are two independent things CSS does, and the shadow boundary treats them differently. **Selector matching** is scoped to a tree. A style rule belongs to some tree — the document, or a particular shadow root — and its selectors are evaluated only against elements in that same tree. A document rule cannot reach into a shadow root, and a shadow root's rules cannot reach out into the document (with the deliberately-sanctioned exceptions `:host`, `::slotted()` and `::part()`). **Inheritance** is not selector matching. It is the step where an element that has no cascaded value for an inherited property takes its parent's computed value. That step runs over the *flattened tree*, which stitches shadow trees into the document: a shadow root's top-level children inherit from the host element, and slotted light-DOM nodes inherit from wherever the slot sits in the shadow tree. Nothing about encapsulation blocks it. So the page's `body { font-family: Georgia }` never "matches" the `<span class="label">` inside the shadow root. It matches `<body>`, and the value is then inherited down through the host and into the shadow tree, arriving at the span with no rule ever having selected it. ## Which properties cross The dividing line is exactly the CSS spec's inherited flag, nothing shadow-specific: - **Cross by inheritance**: `color`, `font` and all its longhands, `line-height`, `letter-spacing`, `word-spacing`, `text-align`, `text-transform`, `white-space`, `visibility`, `cursor`, `direction`, `list-style`, and — importantly — every **custom property** (`--*`). - **Do not cross**: `display`, `background`, `border`, `padding`, `margin`, `width`/`height`, `position`, `box-shadow`, `overflow`, `box-sizing`, `opacity`, `transform`. ```css /* document stylesheet */ body { font-family: Georgia; color: #333; } * { box-sizing: border-box; } /* never applies inside a shadow root */ my-card .label { color: red; } /* matches nothing */ ``` The span ends up Georgia and `#333`, with `box-sizing: content-box`, because the universal selector rule matched only document-tree elements. ## The reset-stylesheet trap This is the most common real-world bite. Teams ship a normalize/reset stylesheet and assume it governs the whole page. Inside every shadow root, none of it applies: margins on `<h1>`, the default `box-sizing`, button font inheritance quirks all revert to UA defaults. A component that looked fine in a demo page (which had a reset and no shadow root) can shift once its markup moves inside a shadow root. Component authors therefore ship their own small reset inside the shadow root rather than relying on the host page's. ## Open vs closed is irrelevant here `attachShadow({ mode: 'open' })` versus `'closed'` only decides whether `element.shadowRoot` returns the root to script. Style encapsulation, inheritance, `::part()` and `::slotted()` behave identically in both modes. A candidate who says page CSS can select into an open root but not a closed one has the model wrong. ## Controlling the inheritance you get for free Sometimes the free inheritance is what you want — a component picks up the page's typography automatically and looks native. Sometimes it is contamination: the page sets `line-height: 3` and your compact toolbar sprawls. The blunt tool is to reset at the doorway: ```css :host { all: initial; /* stops inherited values from the page */ display: block; /* all: initial reset display too, so re-declare */ } ``` `all: initial` on `:host` wipes every inherited property at the host, so nothing arrives from outside. It is heavy-handed: it also resets `display`, so you must set it again, and you lose the "blends into the page" property. A gentler approach is to re-declare only the handful you care about (`font: 16px/1.4 system-ui;`) and let the rest inherit. ## What this leaves for theming Because selectors do not cross, a page cannot restyle a component's internals by writing selectors, no matter how specific. The three sanctioned ways in are: inherited properties (free, but limited to what inherits), **custom properties** the component author wired into `var()` calls, and **`part` names** the author exposed for `::part()`. Everything else is intentionally private — which is the whole point, and also the reason a component with no theming hooks is effectively unstylable.

  • How would you stop the host page's typography from leaking into your component's shadow tree?
    Re-declare the inherited properties at the doorway. `:host { all: initial }` resets every inherited value arriving from outside, but it also clears `display`, so you must re-declare `display: block` (or whatever the component needs) plus the fonts you do want. A lighter version is to just set `font: 16px/1.4 system-ui; color: #111;` on `:host` and let the rest inherit.
  • Name properties people assume are inherited but that stop at the host.
    `box-sizing` is the big one — global resets set it with a universal selector, and neither the selector nor the value reaches inside. Also `background`, `border`, `padding`, `margin`, `display`, `overflow`, `opacity`, `transform` and `box-shadow`. Anything layout- or box-related is non-inherited by design; only text-ish properties and custom properties travel.
  • Does the mode passed to attachShadow change any of this?
    No. `open` and `closed` differ only in whether `element.shadowRoot` returns the root to JavaScript. CSS encapsulation, inheritance through the flattened tree, `::part()` and `::slotted()` behave identically in both. Closed mode is not a stronger style boundary, and it is not a security boundary either.

saying these in an interview costs you the question

  • Shadow DOM blocks all outside styles, including fonts
  • Page selectors reach inside if the root is open
  • Closed mode is what actually blocks page CSS
  • Global resets apply everywhere, shadow roots included
  • Only custom properties can cross the boundary

context

open as a page

A component's internals live in a shadow root that page CSS cannot select. How do CSS custom properties let the page theme it anyway, and what are the limits of that contract?

level: middleimportance: must knowfreq 65%

basics

~20 s

Custom properties are inherited properties, so a value set on the host or any ancestor flows into the shadow tree. The component author must opt in by consuming it in a var() call — only the hooks they wired are themeable, and the names become public API.

open as a page

In a shadow-DOM component, what does putting `part="label"` on an internal element let the page's CSS do, and what can the page still not do through `::part()`?

level: middleimportance: must knowfreq 48%

basics

~20 s

A part name exposes that one element to outside CSS: the page writes my-el::part(label) { ... } and may set any property on it. The page still cannot select the part's descendants, cannot reach unexposed elements, and cannot see parts of nested shadow roots unless they are re-exported.

open as a page

In a component's shadow-root CSS, what can the `::slotted()` pseudo-element actually select, and why do so many attempts to style slotted content with it fail?

level: middleimportance: should knowfreq 38%

basics

~20 s

::slotted() matches only the top-level nodes assigned to a slot, and takes a single compound selector — no combinators, no descendants. It also loses to the page's own rules on those elements, because slotted nodes still belong to the document tree.

open as a page

A page renders 500 instances of a component whose shadow root each contains the same 8 KB `<style>` block. What changes if all of them adopt one constructed `CSSStyleSheet` instead, and how does a theme switch then propagate?

level: seniorimportance: should knowfreq 32%

basics

~20 s

One new CSSStyleSheet() parsed once and assigned to every root's adoptedStyleSheets replaces 500 parses and 500 rule sets with a single shared CSSOM object. Mutating that one sheet with replaceSync updates every adopting root at once.

open as a page

You own a shared web-component library. How do you decide what each component exposes as CSS custom properties versus `::part()` names, and what does each choice cost you later?

level: principalimportance: should knowfreq 28%

basics

~20 s

Custom properties expose values and keep internals refactorable; parts expose whole elements and let consumers apply anything, which freezes that element's structure and box behaviour as de-facto API. Default to tokens; grant parts only where needs are genuinely unbounded.

open as a page