You can see the button. It is right there on the screen. Your test says it does not exist.
You copy the selector from devtools, paste it into the console, and even document.querySelector returns null. At this point most people assume they have made a typo.
You have not. The element is inside a shadow root, and querySelector cannot see through one.
What the shadow DOM is
Some components keep their internal markup in a separate, hidden DOM tree. A video player, a date picker, a design-system component, anything built as a web component.
That separate tree is called a shadow root, and the element it hangs off is the shadow host. In devtools it looks like this:
<my-login-form>
#shadow-root (open)
<input id="email">
<button>Sign in</button>
</my-login-form>
The point of it is encapsulation. Styles inside do not leak out, styles outside do not leak in, and document.querySelector('#email') finds nothing, because #email is not in the main document.
That is a feature for the developer who built the component. It is an obstacle for your test.
Open and closed. A shadow root is created in one of two modes. An open root can be reached from JavaScript. A closed one cannot, by design. Almost everything you meet in practice is open, and closed roots are the one case where no tool can help you.
How to tell this is your problem
Three signs, and any one of them is usually enough:
- Devtools shows a line that reads
#shadow-rootabove your element. document.querySelector('your-selector')returnsnullin the console, even though you can see the element.- The element sits inside a tag with a hyphen in its name, like
<my-login-form>. A hyphen is required in a custom element name, so it is a strong hint.
Playwright: nothing to do
Playwright handles this for you. From the documentation: "All locators in Playwright by default work with elements in Shadow DOM."
// Just works, even though #email is inside a shadow root
await page.locator('#email').fill('demo@nimbus.app');
await page.getByRole('button', { name: 'Sign in' }).click();
You write the same code you would write for an ordinary element, as though the shadow root were not there.
Two documented limits. XPath does not pierce shadow roots, and closed shadow roots are not supported. So if you were reaching for XPath, this is one more reason not to.
Cypress: the .shadow() command
Cypress needs to be told. Get the host element, call .shadow(), then search inside:
cy.get('my-login-form') // the shadow host
.shadow() // step into its shadow root
.find('#email') // now search inside
.type('demo@nimbus.app');
cy.get('my-login-form').shadow().find('button').click();
Note it is .find() after .shadow(), not .get(). cy.get() searches from the document root and would put you straight back outside.
The config shortcut. If your app is full of web components, turning this on saves a lot of repetition:
// cypress.config.js
module.exports = defineConfig({
e2e: {
includeShadowDom: true,
},
});
Now ordinary cy.get() looks inside shadow roots too. You can also set it for one test with { includeShadowDom: true } as a command option.
Turn it on globally only if you need it. It makes every query do more work, and it can make a selector match something you did not intend.
Selenium: get the shadow root first
Selenium 4 exposes the shadow root as an object you search from.
Java
WebElement host = driver.findElement(By.cssSelector("my-login-form"));
SearchContext shadow = host.getShadowRoot();
shadow.findElement(By.cssSelector("#email")).sendKeys("demo@nimbus.app");
shadow.findElement(By.cssSelector("button")).click();
Python
host = driver.find_element(By.CSS_SELECTOR, "my-login-form")
shadow = host.shadow_root
shadow.find_element(By.CSS_SELECTOR, "#email").send_keys("demo@nimbus.app")
shadow.find_element(By.CSS_SELECTOR, "button").click()
JavaScript
const host = await driver.findElement(By.css('my-login-form'));
const shadow = await host.getShadowRoot();
await shadow.findElement(By.css('#email')).sendKeys('demo@nimbus.app');
await shadow.findElement(By.css('button')).click();
The shadow root is a search context, not an element. You cannot click it, and you cannot read text from it. You only search from it.
CSS only. A shadow root accepts CSS selectors. XPath does not work here, in any language.
Nested shadow roots
Components contain components, so shadow roots nest. Each level needs its own step.
// Cypress
cy.get('my-app').shadow().find('my-login-form').shadow().find('#email');
// Selenium
SearchContext outer = driver.findElement(By.cssSelector("my-app")).getShadowRoot();
SearchContext inner = outer.findElement(By.cssSelector("my-login-form")).getShadowRoot();
inner.findElement(By.cssSelector("#email")).sendKeys("demo@nimbus.app");
Playwright still needs nothing. It walks the whole way down on its own.
Three levels of this is where teams usually stop and go looking for a better approach, which brings us to the part that matters most.
The fix that beats all three: ask for a handle
Everything above is how to work around the shadow boundary. The better move is often to not need to.
If your team owns the component, add a test attribute to the host and expose what the test needs. A component author can also reflect a value to an attribute on the host, so the test never has to reach inside at all:
<my-login-form data-testid="login" data-state="submitting">
Now the test asserts on the host, and the component's internals stay free to change. That is the whole point of encapsulation, and a test that reaches through it is a test that breaks the next time someone refactors the component.
I have watched a suite break across forty tests because a design-system upgrade renamed an internal class. Every one of those tests was reaching through a shadow root to assert on something the component never promised.
If you do not own the component, reach inside. If you do own it, add the handle.
Quick reference
| Playwright | Cypress | Selenium | |
|---|---|---|---|
| Needs special code | No | Yes | Yes |
| How | Automatic | .shadow() |
getShadowRoot() |
| Global option | n/a | includeShadowDom |
n/a |
| Nested roots | Automatic | Chain .shadow() |
Chain per level |
| XPath works | No | No | No |
| Closed roots | Not supported | Not supported | Not supported |
Common mistakes
- Using XPath. It does not pierce a shadow root in any of the three tools. Switch to CSS.
- Using
cy.get()after.shadow(). Use.find(), or you jump back to the document root. - Treating the shadow root as an element. In Selenium it is a search context. You cannot click it.
- Turning on
includeShadowDomglobally to fix one test. It slows every query and widens every selector. - Assuming it failed because the root is closed. Closed roots are rare. Check devtools before concluding that.
Frequently asked questions
Why can devtools see the element but my test cannot?
Devtools deliberately shows you inside shadow roots. document.querySelector does not. That gap is what makes this confusing.
Can I test a closed shadow root? No. Closed mode exists to prevent outside access, and no framework works around it. If you own the component, change it to open mode for test builds, or expose state on the host.
Does XPath ever work with the shadow DOM? No. Use CSS selectors.
Is the shadow DOM the same as an iframe? No, and it is worth keeping them apart. An iframe is a separate document with its own URL, and you switch into it. A shadow root is part of the same document, just encapsulated. Handling iframes covers the other one.
Which tool is easiest for web components? Playwright, clearly. It is the only one where you write no special code at all.
Remember this
Your element is not missing. It is in a separate DOM tree, and querySelector stops at the boundary.
Playwright walks through on its own. Cypress needs .shadow(). Selenium needs getShadowRoot(). None of them can use XPath, and none of them can open a closed root.
And if your team owns the component, the best fix is to stop reaching inside it and expose a handle on the host instead.
Practice locators against real components in the practice editor, where the template apps are ordinary DOM, so you can get the fundamentals right first. Locators are the skill underneath all of this.