Hero Background

Next-Gen App & Browser Testing Cloud

Trusted by 2 Mn+ QAs & Devs to accelerate their release cycles

Next-Gen App & Browser Testing Cloud

Test your website on
3000+ browsers

Get 100 minutes of automation
test minutes FREE!!

Test NowArrowArrow

KaneAI - GenAI Native
Testing Agent

Plan, author and evolve end to
end tests using natural language

Test NowArrowArrow
  • Home
  • /
  • Blog
  • /
  • Automating Shadow DOM in Selenium WebDriver
Selenium JavaAutomationTutorial

How to Automate Shadow DOM in Selenium WebDriver

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

Author

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.

Overview

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.

What Is Shadow DOM in Selenium?

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:

shadow-dom-im-selenium

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.

Why Selenium Fails With Shadow DOM?

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.

Open vs Closed Shadow DOM

Shadow DOM has two modes, and this distinction is critical for automation.

Open Shadow DOM

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 object

Closed Shadow DOM

Closed 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 null

What to do when you encounter closed Shadow DOM:

  • Talk to the development team and ask for open mode or a testing API.
  • Use Chrome DevTools Protocol (CDP) only if necessary, since it is browser-specific and fragile.
  • Consider Playwright for shadow-heavy apps where deeper piercing is required.

For the rest of this guide, I focus on open Shadow DOM, which is what you will deal with in most real-world scenarios.

How to Access Shadow DOM in Selenium

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.

Method 1: getShadowRoot() (Selenium 4+)

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.

Method 2: JavaScriptExecutor

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.

Method 3: Deep Piercing with a Single JS Call

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')"
)
Pro Tip
In Chrome DevTools, right-click any element inside a shadow root and select Copy > Copy JS path. This gives you the exact JavaScript path including all shadow root traversals. Paste it directly into executeScript(). This trick has saved me hours.

Reusable Shadow DOM Utility

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.

Handling Nested Shadow DOM

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
Key points:
  1. You must traverse each level sequentially. No shortcuts.
  2. Each getShadowRoot() call is a round trip to the browser. For 4+ levels, use the single JS call approach or the reusable utility instead.
  3. If any host in the chain doesn't have a shadow root, the entire chain breaks.

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.

Cross-Browser Shadow DOM Behavior

Shadow DOM behavior has been a source of test failures in my experience. Here's the current state:

BrowsergetShadowRoot()JavaScriptExecutorKey Notes
Chrome 96+YesYesW3C compliant. Breaking change in v96.
Edge 96+YesYesChromium-based, same as Chrome.
Firefox 96+YesYesFollows W3C spec.
Safari 16.4+YesYesWebKit. Some CSS selector edge cases.
OperaYesYesChromium-based.
Chromium v96 Breaking Change
Chrome v96 changed how shadow roots are returned via WebDriver. Before v96, 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.

Real-World Shadow DOM Examples

Most tutorials use demo pages. Here are examples from real applications I've had to automate.

Chrome Browser Downloads Page

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 UI Components

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")

Custom Video Players

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()

Debugging Shadow DOM Elements

DevTools Tips

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

Common Errors and Fixes

ErrorCauseFix
NoSuchElementExceptionElement is inside shadow rootAccess shadow root first
NoSuchShadowRootExceptionHost has no shadow rootVerify #shadow-root in DevTools
InvalidArgumentExceptionUsing XPath inside shadow rootSwitch to CSS selectors
StaleElementReferenceExceptionShadow DOM re-renderedRe-locate host and shadow root
executeScript returns nullClosed shadow root or bad selectorVerify mode is open; check CSS in console

Handling Waits with Shadow DOM

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)

Selenium vs Playwright vs Cypress

I get asked this a lot. Here's my honest comparison:

CapabilitySeleniumPlaywrightCypress
Open Shadow DOMYes (manual)Yes (automatic)Yes (shadow())
Closed Shadow DOMNoYes (automatic)No
LanguagesJava, Python, JS, C#, RubyJS, Python, Java, C#JS/TS only
XPath in shadow rootsNoNoNo
Auto-wait for shadowManualBuilt-inBuilt-in
Nested shadow DOMManual per levelAutomaticManual 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.

5 Best Practices of Shadow Dom Automation

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.

Key Takeaways

  • Selenium cannot find Shadow DOM elements with regular locators. You must locate the shadow host first, access its shadow root, then use CSS selectors to find elements inside it.
  • Use getShadowRoot() in Selenium 4+ for most cases. For deeply nested shadow trees (3+ levels), use a single JavaScriptExecutor call that chains through all levels in one shot.
  • XPath never works inside shadow roots. Only CSS selectors are supported. This is a W3C spec rule, not a Selenium bug.
  • Open Shadow DOM is automatable. Closed is not. Open mode (mode: 'open') exposes the shadow root to Selenium. Closed mode returns null and cannot be bypassed.
  • Upgrade to Selenium 4.1+ minimum. Python 4.0 lacks shadow root support entirely, and the Chromium v96 breaking change requires 4.1+ to handle the new ShadowRoot return type.
  • Always encapsulate shadow DOM logic in reusable utilities or page objects. A single findInShadowDom() helper eliminates repetitive traversal code across your test suite.
  • Test across browsers. Chrome, Firefox, Edge, and Safari all support Shadow DOM now, but edge cases still exist, especially in Safari's WebKit.
  • For shadow-heavy apps, consider Playwright. Its locators automatically pierce both open and closed shadow DOM with zero extra code, making it the simpler choice for apps like Salesforce Lightning or Ionic.

Author

Mohammad Faisal Khatri is a Software Testing Professional with 17+ years of experience in manual exploratory and automation testing. He currently works as a Senior Testing Specialist at Kafaat Business Solutions and has previously worked with Thoughtworks, HCL Technologies, and CrossAsyst Infotech. He is skilled in tools like Selenium WebDriver, Rest Assured, SuperTest, Playwright, WebDriverIO, Appium, Postman, Docker, Jenkins, GitHub Actions, TestNG, and MySQL. Faisal has led QA teams of 5+ members, managing delivery across onshore and offshore models. He holds a B.Com degree and is ISTQB Foundation Level certified. A passionate content creator, he has authored 100+ blogs on Medium, 40+ on TestMu AI, and built a community of 25K+ followers on LinkedIn. His GitHub repository “Awesome Learning” has earned 1K+ stars.

Frequently asked questions

Did you find this page helpful?

More Related Hubs

TestMu AI forEnterprise

Get access to solutions built on Enterprise
grade security, privacy, & compliance

  • Advanced access controls
  • Advanced data retention rules
  • Advanced Local Testing
  • Premium Support options
  • Early access to beta features
  • Private Slack Channel
  • Unlimited Manual Accessibility DevTools Tests