// blog/developer/
Back to Blog
Developer · Published September 18, 2026 · 7 min read · By Toine

What Shadow DOM Isolates in a Web Component, and What Leaks Through

What Shadow DOM Isolates in a Web Component, and What Leaks Through

Web Components get written off about once a year and keep shipping anyway. GitHub, YouTube and Salesforce build on them. The reason is unglamorous: they are the only component model the browser understands natively, so they work inside React, Vue, Angular and a plain HTML page without a build step.

I do not build ToolForte with them (it is React). Where I meet them is on the testing side: a supplier's front end uses custom elements, a Playwright selector stops finding a button, and someone has to explain why. That is the angle of this post. What Shadow DOM actually isolates, what still crosses the boundary, and the handful of things that trip teams up once components reach production. Plain JavaScript, no library.

* * *

A custom element is a class with a hyphen in its name

You write a class that extends HTMLElement and register it under a tag name. From then on the browser treats your tag like any built-in element.

`javascript class UserCard extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); }

connectedCallback() { const name = this.getAttribute('name') || 'Unknown'; const role = this.getAttribute('role') || ''; this.shadowRoot.innerHTML = `

${name}
${role}
`; } }

customElements.define('user-card', UserCard); `

Then works anywhere in the page.

Two rules. The tag name must contain a hyphen (user-card yes, usercard no); that is how the browser keeps custom names from colliding with future built-in elements. And the constructor may not read attributes or children, because the element may not have any yet when it runs. Read them in connectedCallback, as above.

One warning about the example. It drops attribute values straight into innerHTML. Fine for a demo, a cross-site scripting hole the moment name comes from user data. In real code, build the nodes and set textContent, or escape first.

Code editor showing custom element definition with Shadow DOM attachment
Code editor showing custom element definition with Shadow DOM attachment
* * *

Shadow DOM blocks selectors and IDs, not variables or events

this.attachShadow({ mode: 'open' }) gives the element a second DOM tree that the outer page cannot style or query. Everything you render inside it stays inside. This is what people mean by encapsulation, and the browser enforces it, which is more than any naming convention ever did.

What the boundary blocks:

  • Page CSS cannot reach elements inside the shadow root, and the component's CSS cannot reach out.
  • IDs inside the shadow root do not clash with IDs in the document.
  • document.querySelector does not see inside. This is why that Playwright selector broke.

What still crosses:

  • CSS custom properties. Set --btn-bg on the page and the component can read it. This is the intended theming channel.
  • Inherited properties such as font-family, color and line-height. Your component picks up the page's typography unless you reset it.
  • Events, with two catches. event.target is rewritten to the host element once the event leaves the shadow root, and a custom event only leaves at all if you dispatch it with bubbles: true, composed: true.

The mode: 'closed' option adds no security; it only hides element.shadowRoot from scripts, including your own test automation. Use open mode unless you have a reason you can write down.

Key takeaway

`this.attachShadow({ mode: 'open' })` gives the element a second DOM tree that the outer page cannot style or query.

* * *

Slots let the page supply content while the component owns the layout

A

`html `

In the component: this.shadowRoot.append(template.content.cloneNode(true)). On the page:

`html Info icon here This is an informational message. `

The unnamed slot takes whatever has no slot attribute; named slots take the rest. It is the same idea as children in React or named slots in Vue, running in the browser itself.

Slotted content is styled by the page, not by the component, because it still lives in the light DOM. From inside the component you can reach it with ::slotted(span), but only the direct child of the slot, nothing deeper. If you need to style deep inside slotted content you are fighting the design; move that markup into the component.

* * *

Four lifecycle callbacks, and you will use two of them

connectedCallback() runs when the element lands in the document. Render here, attach listeners here. It can run more than once: move the element in the DOM and it fires again, so guard anything that must happen only once.

disconnectedCallback() runs on removal. Remove listeners, clear timers, disconnect observers, or they keep the element alive in memory.

attributeChangedCallback(name, oldValue, newValue) runs when an observed attribute changes. You have to opt in per attribute:

`javascript static get observedAttributes() { return ['name', 'status', 'count']; }

attributeChangedCallback(name, oldValue, newValue) { if (oldValue === newValue) return; this.render(); } `

Attributes not in the list never trigger the callback. That is deliberate; otherwise every class or style change on every element would fire it.

adoptedCallback() runs when the element moves to another document. I have never needed it.

The pattern that keeps this readable is one render() method called from both connectedCallback and attributeChangedCallback. And when something does not show up, open DevTools and expand the #shadow-root (open) node under the element. That tree is what the component actually rendered, whatever the page source says.

Browser DevTools inspecting Shadow DOM tree of a custom component
Browser DevTools inspecting Shadow DOM tree of a custom component
* * *

Theme through custom properties and ::part, nothing else

The boundary raises an obvious question: how does the page change how the component looks? Custom properties, because they are the one CSS feature that crosses on purpose.

`javascript // Inside the component this.shadowRoot.innerHTML = ` `; `

The page sets the variables on the host:

`css custom-button { --btn-bg: #10b981; --btn-color: white; --btn-radius: 20px; } `

Three more selectors matter. :host styles the element itself from inside the shadow root. :host([variant="outline"]) styles it based on an attribute, which is how you do variants. And part="label" on an internal element lets the page style it with custom-button::part(label), an escape hatch for the cases a variable cannot cover.

Skip :host-context(). It only ever shipped in Chromium; Firefox and Safari do not have it, and the spec has been trying to drop it for years.

Treat the list of custom properties and parts as the component's public API and document it like one. If the style block is inlined per instance, run it through the CSS Minifier first; every instance carries its own copy.

* * *

What bites once components reach production

Server rendering. Shadow DOM used to require JavaScript, which meant a flash of unstyled content and nothing for crawlers. Declarative Shadow DOM fixes that:

Accessibility. A is a generic element to a screen reader. Nothing about it says button. You add role, tabindex="0" and Enter and Space handling yourself, or set the role through ElementInternals (this.attachInternals().role = 'button') and still write the keyboard handling. Wrapping a real

Styles per instance. Every shadow root computes its own styles. Two hundred table rows each with their own inline

Test automation. Playwright's CSS and text selectors pierce open shadow roots without configuration; XPath does not. Cypress needs includeShadowDom: true or an explicit .shadow() call. A closed shadow root defeats all of it, which is the practical reason to keep mode open. When a tester tells you the element does not exist, the element exists; the selector stopped at the boundary.

Forms. A custom input does not submit with the form until you set static formAssociated = true and call ElementInternals.setFormValue(). Without that, FormData never sees it and your backend validation gets an empty field.

Key takeaway

**Server rendering.** Shadow DOM used to require JavaScript, which meant a flash of unstyled content and nothing for crawlers.

* * *

FAQ

Can I use web components with React or Vue?

Yes. They are plain DOM elements, so every framework can render them. React was the awkward one for years (custom events and boolean attributes did not map cleanly); React 19 fixed that. Vue, Angular and Svelte have handled them well for a long time.

Should I use web components instead of a framework?

For a whole application, I would not. State management, routing and tooling are where React or Vue earn their keep. Web components win for a shared design system that has to work across teams on different stacks, and for widgets embedded in pages you do not control. Plenty of teams use both.

Are they faster than framework components?

Not in a way you will measure. There is no virtual DOM diff, but there is also no batching of updates unless you write it. The reason to pick them is interoperability, not speed.

Is Shadow DOM required for a web component?

No. A custom element without a shadow root is still a custom element; its styles just live in the page like everything else. For logic-only elements, or components that should inherit the site's CSS anyway, skip it. For UI components that must look the same on every page they land on, use it.