Hero Background

Power Your Software Testing with AI Agents and Cloud

The Native AI-Agentic Cloud Platform to Supercharge Quality Engineering. Test Intelligently and Ship Faster.

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.

Last Updated on:

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.

Key Takeaways

  • Selenium cannot reach elements inside a Shadow DOM with ordinary locators, so a test must find the shadow host first, get its shadow root, and only then locate the inner element with a CSS selector.
  • Selenium 4 adds a getShadowRoot() method on the shadow host, while JavaScriptExecutor running return arguments[0].shadowRoot retrieves the same shadow root in both Selenium 3 and Selenium 4.
  • Open shadow roots, created with mode: 'open', can be automated, while closed roots created with mode: 'closed' return null, and neither getShadowRoot() nor JavaScriptExecutor can get past them.
  • XPath never works inside a shadow root because the shadow tree is isolated from the main DOM tree, so CSS selectors are the only locator strategy Selenium supports there.
  • Nested shadow trees, which Salesforce Lightning Web Components can stack 3 to 5 levels deep, need either sequential getShadowRoot() calls or one JavaScript chain that pierces every level in a single round trip.
  • Selenium 4.1 or newer is the practical minimum for Shadow DOM work, because Python Selenium 4.0 shipped without shadow root support and Chromium v96 changed executeScript to return a ShadowRoot object instead of a WebElement.
  • Keeping shadow root traversal inside a reusable utility method or a page object stops the same traversal code from being copied across tests and keeps the test methods readable.
  • Chrome 96+, Edge 96+, Firefox 96+, Safari 16.4+, and Opera all support Shadow DOM, and running the suite on the TestMu AI Selenium Grid across 3,000+ browser and OS combinations catches the remaining WebKit edge cases.

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.

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.

Shadow DOM traversal is one of several modernization patterns covered in Selenium AI, which walks through how AI-driven locator strategies, self-healing selectors, and MCP-aware agents handle Shadow DOM, dynamic IDs, and other edge cases that traditional Selenium scripts struggle with.

Agentic tooling approaches the same boundary from the other side. KaneAI, the GenAI native testing agent from TestMu AI, takes test steps as plain English instructions, and its smart element detection re-anchors those steps when the component markup shifts, so a renamed node inside a web component does not end the run. The authored tests export to Selenium, Playwright, Cypress, or Appium and execute on the TestMu AI grid.

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.

Key Takeaway: Shadow DOM has two modes, and the mode decides whether Selenium can automate the component at all. Open mode exposes the shadowRoot property on the host, so getShadowRoot() and JavaScriptExecutor both reach the elements inside. Closed mode returns null, which leaves asking the development team for open mode or a testing API, using the Chrome DevTools Protocol, or moving shadow heavy suites to Playwright.

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:
  • You must traverse each level sequentially. No shortcuts.
  • Each getShadowRoot() call is a round trip to the browser. For 4+ levels, use the single JS call approach or the reusable utility instead.
  • 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.

Author

...

Navin Chandra

Blogs: 5

  • Linkedin

Navin Chandra is a Member of Technical Staff at TestMu AI (formerly LambdaTest), building the open-source automation that powers its Selenium and Appium cloud grid. A committer to both Selenium and Appium, he implemented WebDriver BiDi support in Selenium for real-time browser events and bidirectional control and is developing Apple's iOS RemoteXPC protocol in Appium to enable low-level wireless communication with iOS system services. He contributes to Selenium across multiple language bindings as a member of the Selenium GitHub organization. He has served as a Google Summer of Code mentee and mentor at openSUSE and an LFX mentee at CNCF's KubeArmor, and is a SUSE Certified Deployment Specialist. Navin holds a B.Tech in Computer Science.

Reviewer

...

Srinivasan Sekar

Reviewer

  • Linkedin

Srinivasan Sekar is Director of Engineering at TestMu AI (formerly LambdaTest), where he leads engineering and open-source initiatives behind the Selenium and Appium automation grid and owns TestMu AI's MCP Server. A committer to Appium and a contributor to Selenium, WebdriverIO, Taiko, and AppiumTestDistribution, he brings over 15 years of experience in quality engineering and open-source technologies. He is the author of the Apress book 'The MCP Standard: A Developer's Guide to Building Universal AI Tools with the Model Context Protocol,' a Certified Kubernetes and Cloud Native Associate, and an international conference speaker. Before TestMu AI he spent over eight years at Thoughtworks as a Principal Consultant and Quality Architect. Srinivasan holds a B.Tech in Information Technology from Anna University.

Add to Google preferred sources

Summarise with AI

Copied to Clipboard!
...

3000+ Browsers. One Platform.

See exactly how your site performs everywhere.

Try it free
...

Write Tests in Plain English with KaneAI

Create, debug, and evolve tests using natural language.

Try for free

Shadow DOM in Selenium FAQs

Did you find this page helpful?

More Related Blogs

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