Next-Gen App & Browser Testing Cloud
Trusted by 2 Mn+ QAs & Devs to accelerate their release cycles

Learn about Shadow DOM in Selenium, how to find elements using getShadowRoot(), JavaScriptExecuter and automate Shadow DOM with Selenium.

Faisal Khatri
March 4, 2026
I once lost an entire sprint because a login button “didn’t exist.”
It existed.
It just lived inside a Shadow DOM.
If your Selenium test keeps failing with NoSuchElementException even though the element is clearly visible in DevTools, you're likely dealing with Shadow DOM — and Selenium won’t handle it the way you expect.
Let’s fix that properly.
What Is Shadow DOM in Selenium?
Shadow DOM in Selenium means web elements are inside an encapsulated shadow root, not the main DOM tree. Because of this boundary, regular Selenium locators often fail with NoSuchElementException unless the script first enters the shadow root.
How Do You Access Shadow DOM Elements?
For open shadow roots, the standard approach is getShadowRoot() in Selenium 4+ or JavaScriptExecutor with return arguments[0].shadowRoot. Closed shadow roots are intentionally inaccessible and require developer support or alternate test design.
How Do You Handle Nested Shadow DOM?
Nested shadow DOM automation requires sequential traversal of each shadow host or a single deep JavaScript chain. This pattern appears in modern component frameworks, design systems, and browser-internal pages.
What Makes Shadow DOM Automation Reliable?
Reliable Shadow DOM testing in Selenium depends on CSS selectors, reusable utility methods, explicit waits for dynamic rendering, and cross-browser validation across Chrome, Edge, Firefox, and Safari.
Shadow DOM is a web standard that allows developers to encapsulate HTML elements inside a separate DOM tree attached to a host element.
In simple terms:
Main DOM → visible page, Shadow DOM → hidden, isolated mini-DOM.
Here is what that looks like in practice:

In this example, the <button id="submit"> inside the shadow root is invisible to driver.findElement(By.id("submit")). Selenium will throw NoSuchElementException because it only searches the regular DOM tree. That is exactly the situation many teams hit at the start.
Selenium cannot directly access elements inside a shadow root using traditional locators unless you explicitly traverse through the shadow host first. That is the core issue.
When you run:
driver.findElement(By.id("login-button"));Selenium searches the main DOM.
But if the element is inside #shadow-root (open), Selenium never sees it.
It is not broken. It is isolated by design.
Shadow DOM has two modes, and this distinction is critical for automation.
Open mode (mode: 'open') exposes the shadowRoot property on the host element. JavaScript on the page, and Selenium, can access internal elements.
// Open: external code CAN access it
const shadowRoot = host.attachShadow({ mode: 'open' });
console.log(host.shadowRoot); // Returns the ShadowRoot objectClosed mode (mode: 'closed') does not expose the shadowRoot property. It returns null. Neither getShadowRoot() nor JavaScriptExecutor can get through.
// Closed: external code CANNOT access it
const shadowRoot = host.attachShadow({ mode: 'closed' });
console.log(host.shadowRoot); // Returns nullWhat to do when you encounter closed Shadow DOM:
For the rest of this guide, I focus on open Shadow DOM, which is what you will deal with in most real-world scenarios.
There are two primary methods: getShadowRoot() (Selenium 4+) and JavaScriptExecutor (all versions). I'll also show a deep-piercing technique for nested trees and a reusable utility I use in every project.
This is my preferred approach for most situations. Clean, readable, and works well for one or two levels of shadow nesting.
WebDriver driver = new ChromeDriver();
driver.get("https://bonigarcia.dev/selenium-webdriver-java/shadow-dom.html");
// Step 1: Find the shadow host in the regular DOM
WebElement shadowHost = driver.findElement(By.id("content"));
// Step 2: Get the shadow root
SearchContext shadowRoot = shadowHost.getShadowRoot();
// Step 3: Find elements inside using CSS selectors
WebElement textElement = shadowRoot.findElement(By.cssSelector("p"));
System.out.println(textElement.getText()); // "Hello Shadow DOM"
from selenium import webdriver
from selenium.webdriver.common.by import By
driver = webdriver.Chrome()
driver.get("https://bonigarcia.dev/selenium-webdriver-java/shadow-dom.html")
shadow_host = driver.find_element(By.ID, "content")
shadow_root = shadow_host.shadow_root
text_element = shadow_root.find_element(By.CSS_SELECTOR, "p")
print(text_element.text) # "Hello Shadow DOM"
const { Builder, By } = require('selenium-webdriver');
let driver = await new Builder().forBrowser('chrome').build();
await driver.get('https://bonigarcia.dev/selenium-webdriver-java/shadow-dom.html');
let shadowHost = await driver.findElement(By.id('content'));
let shadowRoot = await shadowHost.getShadowRoot();
let textElement = await shadowRoot.findElement(By.css('p'));
console.log(await textElement.getText()); // "Hello Shadow DOM"
If the shadow root doesn't exist on the element, this throws NoSuchShadowRootException.
This works in Selenium 3 and 4. I reach for this when dealing with deeply nested shadow trees where chaining getShadowRoot() calls becomes unwieldy.
WebElement shadowHost = driver.findElement(By.id("content"));
JavascriptExecutor js = (JavascriptExecutor) driver;
SearchContext shadowRoot = (SearchContext) js.executeScript(
"return arguments[0].shadowRoot", shadowHost
);
WebElement textElement = shadowRoot.findElement(By.cssSelector("p"));
shadow_host = driver.find_element(By.ID, "content")
shadow_root = driver.execute_script("return arguments[0].shadowRoot", shadow_host)
text_element = shadow_root.find_element(By.CSS_SELECTOR, "p")
let shadowHost = await driver.findElement(By.id('content'));
let shadowRoot = await driver.executeScript('return arguments[0].shadowRoot', shadowHost);
let textElement = await shadowRoot.findElement(By.css('p'));
To learn more about JavaScriptExecutor, check out my blog on how to use JavaScriptExecutor in Selenium WebDriver.
For deeply nested shadow trees (3+ levels), I write a single JavaScript command that traverses all levels without returning to the Selenium context between each one. Much more efficient than multiple round trips.
WebElement deepElement = (WebElement) ((JavascriptExecutor) driver).executeScript(
"return document.querySelector('#host-level-1')" +
".shadowRoot.querySelector('#host-level-2')" +
".shadowRoot.querySelector('#host-level-3')" +
".shadowRoot.querySelector('#target-element')"
);
deep_element = driver.execute_script(
"return document.querySelector('#host-level-1')"
".shadowRoot.querySelector('#host-level-2')"
".shadowRoot.querySelector('#host-level-3')"
".shadowRoot.querySelector('#target-element')"
)
executeScript(). This trick has saved me hours.
I never scatter shadow root traversal logic across individual test methods. Instead, I use this utility in every project:
public class ShadowDomUtils {
public static WebElement findInShadowDom(WebDriver driver, String... selectors) {
String script = "let el = document.querySelector(arguments[0]);";
for (int i = 1; i < selectors.length; i++) {
script += " el = el.shadowRoot.querySelector(arguments[" + i + "]);";
}
script += " return el;";
return (WebElement) ((JavascriptExecutor) driver)
.executeScript(script, (Object[]) selectors);
}
}
// Usage: pierces through 2 shadow roots to reach target
WebElement target = ShadowDomUtils.findInShadowDom(
driver, "#outer-host", "#inner-host", "#target-button"
);
target.click();
def find_in_shadow_dom(driver, *selectors):
script = "let el = document.querySelector(arguments[0]);"
for i in range(1, len(selectors)):
script += f" el = el.shadowRoot.querySelector(arguments[{i}]);"
script += " return el;"
return driver.execute_script(script, *selectors)
# Usage
target = find_in_shadow_dom(driver, "#outer-host", "#inner-host", "#target-button")
target.click()
This utility has been the single most useful piece of code in my shadow DOM automation toolkit. Drop it into your framework and you'll never write raw shadow root traversal again.
Many modern web apps have shadow DOM elements nested inside other shadow DOM elements. Salesforce LWC can go 3 to 5 levels deep. I've seen teams struggle with this, so let me walk through it.
Using the Watir demo page as our example:
driver.get("http://watir.com/examples/shadow_dom.html");
// Level 1
WebElement shadowHost = driver.findElement(By.id("shadow_host"));
SearchContext shadowRoot = shadowHost.getShadowRoot();
String someText = shadowRoot.findElement(
By.cssSelector("#shadow_content > span")).getText();
// Level 2
WebElement nestedHost = shadowRoot.findElement(
By.cssSelector("#nested_shadow_host"));
SearchContext nestedRoot = nestedHost.getShadowRoot();
String nestedText = nestedRoot.findElement(
By.cssSelector("#nested_shadow_content > div")).getText();
// Fluent chaining style (concise alternative):
String nestedText = driver.findElement(By.id("shadow_host"))
.getShadowRoot()
.findElement(By.cssSelector("#nested_shadow_host"))
.getShadowRoot()
.findElement(By.cssSelector("#nested_shadow_content > div"))
.getText();
driver.get("http://watir.com/examples/shadow_dom.html")
shadow_host = driver.find_element(By.ID, "shadow_host")
shadow_root = shadow_host.shadow_root
some_text = shadow_root.find_element(
By.CSS_SELECTOR, "#shadow_content > span").text
nested_host = shadow_root.find_element(
By.CSS_SELECTOR, "#nested_shadow_host")
nested_root = nested_host.shadow_root
nested_text = nested_root.find_element(
By.CSS_SELECTOR, "#nested_shadow_content > div").text
getShadowRoot() call is a round trip to the browser. For 4+ levels, use the single JS call approach or the reusable utility instead.Check out my blog on Shadow Root in Selenium Java for more advanced nested shadow DOM patterns. The complete source code is also available on my GitHub repository.
Shadow DOM behavior has been a source of test failures in my experience. Here's the current state:
| Browser | getShadowRoot() | JavaScriptExecutor | Key Notes |
|---|---|---|---|
| Chrome 96+ | Yes | Yes | W3C compliant. Breaking change in v96. |
| Edge 96+ | Yes | Yes | Chromium-based, same as Chrome. |
| Firefox 96+ | Yes | Yes | Follows W3C spec. |
| Safari 16.4+ | Yes | Yes | WebKit. Some CSS selector edge cases. |
| Opera | Yes | Yes | Chromium-based. |
executeScript("return arguments[0].shadowRoot", element) returned a WebElement. After v96, it returns a ShadowRoot object, a different type entirely. The fix: Upgrade to Selenium 4.1+. It handles this automatically. Honestly, if you're still on Selenium 3, just upgrade. It will save you more time than any workaround.
Python gotcha: Python's Selenium 4.0 did NOT include shadow root support. You need Selenium 4.1+ for the shadow_root property. Also, ShadowRoot in Python only supports By.CSS_SELECTOR. Using By.ID or By.XPATH inside a shadow root will fail.
Most tutorials use demo pages. Here are examples from real applications I've had to automate.
Chrome's internal pages use nested Shadow DOM extensively. I've automated these for testing browser extensions and download verification.
driver.get("chrome://downloads")
# Single JS call is cleanest for Chrome's deep nesting
search_input = driver.execute_script(
"return document.querySelector('downloads-manager')"
".shadowRoot.querySelector('downloads-toolbar')"
".shadowRoot.querySelector('cr-toolbar')"
".shadowRoot.querySelector('#searchInput')"
)
search_input.send_keys("my-file.pdf")
Shoelace is a popular web component library. If your app uses Shoelace buttons or inputs, you need to pierce the shadow DOM:
// Clicking a Shoelace button
WebElement slButton = driver.findElement(By.cssSelector("sl-button"));
SearchContext buttonShadow = slButton.getShadowRoot();
buttonShadow.findElement(By.cssSelector("button.button")).click();
// Typing into a Shoelace input
WebElement slInput = driver.findElement(By.cssSelector("sl-input"));
SearchContext inputShadow = slInput.getShadowRoot();
inputShadow.findElement(By.cssSelector("input.input__control"))
.sendKeys("Hello World");
# Clicking a Shoelace button
sl_button = driver.find_element(By.CSS_SELECTOR, "sl-button")
button_shadow = sl_button.shadow_root
button_shadow.find_element(By.CSS_SELECTOR, "button.button").click()
# Typing into a Shoelace input
sl_input = driver.find_element(By.CSS_SELECTOR, "sl-input")
input_shadow = sl_input.shadow_root
input_shadow.find_element(By.CSS_SELECTOR, "input.input__control") \
.send_keys("Hello World")
Many custom video players use Shadow DOM for controls. Accessing play buttons or volume sliders requires shadow root traversal:
video_player = driver.find_element(By.CSS_SELECTOR, "custom-video-player")
player_shadow = video_player.shadow_root
play_button = player_shadow.find_element(By.CSS_SELECTOR, ".play-btn")
play_button.click()
Enable full shadow DOM visibility: Open Chrome DevTools > Settings > Elements > check "Show user agent shadow DOM". This reveals shadow roots inside native elements like <input> and <video>.
Copy JS path (the biggest time-saver): Right-click any element inside a shadow root in the Elements panel > Copy > Copy JS path. Paste it into executeScript(). Done.
Verify from console before writing code:
let host = document.querySelector('#my-component');
console.log(host.shadowRoot);
// ShadowRoot object = open, accessible
// null = closed or no shadow root
| Error | Cause | Fix |
|---|---|---|
NoSuchElementException | Element is inside shadow root | Access shadow root first |
NoSuchShadowRootException | Host has no shadow root | Verify #shadow-root in DevTools |
InvalidArgumentException | Using XPath inside shadow root | Switch to CSS selectors |
StaleElementReferenceException | Shadow DOM re-rendered | Re-locate host and shadow root |
executeScript returns null | Closed shadow root or bad selector | Verify mode is open; check CSS in console |
Standard ExpectedConditions can't reach inside shadow roots. Here's the pattern I use:
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
WebElement target = wait.until(d -> {
try {
WebElement host = d.findElement(By.id("shadow-host"));
SearchContext root = host.getShadowRoot();
return root.findElement(By.cssSelector("#target-element"));
} catch (NoSuchElementException | NoSuchShadowRootException e) {
return null;
}
});
from selenium.webdriver.support.ui import WebDriverWait
def find_shadow_element(driver):
try:
host = driver.find_element(By.ID, "shadow-host")
root = host.shadow_root
return root.find_element(By.CSS_SELECTOR, "#target-element")
except Exception:
return False
element = WebDriverWait(driver, 10).until(find_shadow_element)
I get asked this a lot. Here's my honest comparison:
| Capability | Selenium | Playwright | Cypress |
|---|---|---|---|
| Open Shadow DOM | Yes (manual) | Yes (automatic) | Yes (shadow()) |
| Closed Shadow DOM | No | Yes (automatic) | No |
| Languages | Java, Python, JS, C#, Ruby | JS, Python, Java, C# | JS/TS only |
| XPath in shadow roots | No | No | No |
| Auto-wait for shadow | Manual | Built-in | Built-in |
| Nested shadow DOM | Manual per level | Automatic | Manual chaining |
Playwright's advantage: page.locator("#target-inside-shadow").click() just works, no shadow root traversal needed.
When Selenium is still the right choice: Broadest language support, largest community, most integrations, W3C standard. If your team already has a Selenium framework, adding shadow DOM handling with the techniques in this guide is straightforward. I still use Selenium for most of my projects.
These are the five practices I've settled on after working with shadow DOM across multiple projects:
1. Always use CSS selectors inside shadow roots. XPath does not work. This is a W3C spec constraint, not a bug. I've seen teams waste hours trying to force it.
2. Build reusable utility methods. Don't scatter traversal logic across tests. Use the findInShadowDom() utility or encapsulate access in page objects.
3. Keep shadow root logic in page objects. The Page Object Model keeps tests clean:
public class SettingsPage {
public void setUsername(String username) {
WebElement host = driver.findElement(By.cssSelector("settings-panel"));
host.getShadowRoot().findElement(By.cssSelector("input.username"))
.sendKeys(username);
}
}
// Test stays clean
settingsPage.setUsername("john.doe");
4. Use custom waits for dynamic shadow elements. Shadow DOM components often render asynchronously. Use the wait patterns from the debugging section above.
5. Test across browsers. Shadow DOM behavior still has edge cases across browsers, especially Safari. Run your shadow DOM tests on a cloud grid to catch these early. TestMu AI's Selenium Grid supports 3000+ browser and OS combinations for exactly this purpose.
Did you find this page helpful?
More Related Hubs
TestMu AI forEnterprise
Get access to solutions built on Enterprise
grade security, privacy, & compliance