---
title: "Closed shadow roots: what Playwright cannot see"
description: "Stock Playwright finds nothing inside a closed shadow root, in Firefox and Chromium alike. Measured, with the page that reproduces it and what this engine does instead."
parent: "The Automation Layer"
grand_parent: "Guides"
nav_order: 45
---
# Closed shadow roots: what Playwright cannot see
A closed shadow root makes `element.shadowRoot` return `null`, and stock
Playwright locators stop at that boundary: measured on 5 September 2026
against a page with one open and one closed root, stock Firefox and stock
Chromium both matched the open content and returned zero matches for the
closed content. The engine this wiki documents returns one match for the
same closed content, and the page still reads `null`.
For the ordinary case, open shadow roots, Playwright crosses them by itself
and there is nothing to configure: see
[how to scrape shadow DOM content with Playwright](how-to-scrape-shadow-dom-playwright.md).
This page is about the other case, and it starts with a number rather than
an opinion.
## The measurement, and the page that reproduces it
The test page defines two custom elements. One attaches its shadow root with
`mode: "open"`, the other with `mode: "closed"`, and each puts a unique
string inside. Served from `127.0.0.1`, so no network variability, and the
same file for every arm.
```html
```
Three arms, one instrument, same page:
| arm | `#inner-open` | `#inner-closed` | text inside the closed root | `closed.shadowRoot` |
|---|---|---|---|---|
| Playwright, stock Firefox | 1 match | 0 matches | 0 matches | `null` |
| Playwright, stock Chromium | 1 match | 0 matches | 0 matches | `null` |
| Playwright, this engine | 1 match | **1 match** | **1 match** | `null` |
The stock rows are exactly what Playwright's own documentation promises:
"Closed-mode shadow roots are not supported." The third row is this engine,
and the last column is the part that matters most: from the page's point of
view nothing changed. `element.shadowRoot` still reads `null`, because the
resolution happens beneath the page's JavaScript rather than by rewriting
what the page can see.
## Why the boundary exists at all
`attachShadow({ mode: "closed" })` is defined to hide the subtree from
outside code. MDN puts it plainly: elements inside the shadow root "cannot
be accessed from JavaScript via the `shadowRoot` property, which is set to
`null`". The mode does not hide anything from the component itself, because
`attachShadow()` returns the root to the code that called it, and that code
keeps the reference for the life of the page.
So a closed root is an encapsulation choice by the component author, not a
protection against automation, and it is not a detection surface either.
Nothing about a closed root tells a site who is visiting.
## How to recognise one in ten seconds
Three signals together, never one alone. The element renders visible
content. A selector aimed inside it returns nothing. And the host's
`.shadowRoot`, read from the console, comes back `null`.
That last signal does not separate a closed root from an element with no
shadow root at all. Pair it with the host's own `textContent`: on our test
page the closed host returns an empty string while text is plainly on
screen, which is the tell. Content you can see, text you cannot read from
the host, and a `null` shadow root is a closed root every time.
```python
from invisible_playwright import InvisiblePlaywright
with InvisiblePlaywright(seed=42, headless=True) as browser:
page = browser.new_page()
page.goto("http://127.0.0.1:8731/page.html")
host = page.query_selector("closed-widget")
print(host.evaluate("el => el.shadowRoot !== null")) # False: null, as the spec says
print(host.evaluate("el => el.textContent")) # '' while text is on screen
print(page.locator("#inner-closed").count()) # 1 on this engine, 0 on stock
```
## What still works when a selector will not reach
On stock Playwright the closed subtree is off limits to selectors, and
three routes remain, each for a different reason.
The component's own script keeps full access, so any interface the author
deliberately exposed on the host still answers. On the test page above the
component sets a `readIt()` method on its host, and `page.evaluate` calling
that method returned the closed string on every arm, stock included. A well
built component publishes properties, methods, or events dispatched with
`composed: true`, and that public surface is the door the author intended.
Input reaches the component regardless. The host sits in ordinary light DOM,
so it has a real bounding box, it can be clicked, and keyboard focus moves
into the controls inside it. Neither route lets you name what you are
touching, but both reach it.
And the data usually exists before the component renders it. Most closed
components are filled from a network response, and
[waiting for that response](wait-for-specific-api-response-playwright.md)
sidesteps the rendered tree entirely.
## The workaround that is worse than the problem
You will find snippets that patch `Element.prototype.attachShadow` before
the page's scripts run so every later call becomes `mode: "open"`. It has
two problems. It only wins a timing race you rarely control, and it changes
what the page itself can observe: after the patch, `element.shadowRoot`
stops being `null` on components whose authors asked for `null`. That is a
difference a consistency check can read, which is the opposite of the goal
on a wiki like this one.
The measurement above is the argument against it. Reaching the content while
leaving `shadowRoot` at `null` is strictly better than reaching it by making
the page lie about itself.
## Short answers to the questions that lead here
**Why does my Playwright locator find nothing when the element is visible?**
If the host's `.shadowRoot` reads `null` while text renders on screen, the
content is inside a closed shadow root. Stock Playwright locators stop
there, in Firefox and Chromium alike.
**Does Playwright support closed shadow roots?** No. Its documentation says
so directly, and the measurement above confirms it on both stock engines:
zero matches for content that is plainly rendered.
**How do I tell an open root from a closed one?** Read `element.shadowRoot`.
An object means open. `null` means closed, or no shadow root at all, and the
host's empty `textContent` next to visible text separates those two.
**Can I force a closed root open?** You can patch `attachShadow` before the
component runs, and you should not. It depends on a race you do not control
and it changes what the page can observe about itself.
**What if the data only exists inside a closed root?** Call whatever the
component exposes on its host, or read the network response that filled it.
On this engine, a locator also reaches the content directly.
**See also:** [scraping open shadow DOM with Playwright](how-to-scrape-shadow-dom-playwright.md),
[scraping iframe content with Playwright](how-to-scrape-iframe-content-playwright.md),
and [waiting for a specific API response](wait-for-specific-api-response-playwright.md).
## Sources
- MDN, `Element.attachShadow()`,
https://developer.mozilla.org/en-US/docs/Web/API/Element/attachShadow -
the `closed` mode definition and the quoted sentence about `shadowRoot`
being set to `null`. Read 5 September 2026.
- Playwright, Locators,
https://playwright.dev/python/docs/locators - "All locators in Playwright
by default work with elements in Shadow DOM" and "Closed-mode shadow roots
are not supported". Read 5 September 2026.
- Our own measurement, 5 September 2026: the page above served from
`127.0.0.1`, run against stock Playwright Firefox, stock Playwright
Chromium and this engine, same script for all three arms.
---
*From [invisible_playwright](https://github.com/feder-cr/invisible_playwright).
The three-arm table is our own run and the test page is printed above so you
can reproduce it rather than take our word for it.*