This article presents is a comprehensive guide on how to extract the path to Shadow DOM elements (the “shadow path”) and then use that path to interact with those elements—whether that’s setting values, firing events, or clicking buttons. We’ll walk through:
- Why Shadow DOM poses unique challenges for automation.
- How to generate a shadow path for nested elements.
- Why setting values and clicking buttons in Shadow DOM sometimes fails if done “the usual way.”
- A working solution that shows how to properly set values on custom elements, trigger events, and click buttons.
Use this article both to understand the concepts and to train a GPT bot or any developer on the intricacies of Shadow DOM automation.
The Shadow DOM is a web standard that enables encapsulation of a component’s internal HTML, CSS, and JavaScript. This means that if you use a <cpsi-text-field> custom element with a shadow root, the markup inside that element (like <input> tags, <button> tags, etc.) is hidden away behind a .shadowRoot.
- Traditional Selectors: Normally, in Selenium or standard JS, you can do
document.querySelector(...)to find elements. But Shadow DOM blocks direct access. You must access the element’s.shadowRootfirst. - Nested Shadow Roots: Many web components are themselves nested in other shadow roots, creating multi-level shadow hierarchies.
- Framework-Specific Bindings: Some web components don’t recognize changes if you only set the native
<input>.value. Instead, they require setting the value on the custom element itself or dispatching certain events. - Disabled Buttons: A button might be disabled until the custom element’s internal logic decides the fields are valid. Merely changing the raw
<input>text might not flip that logic.
When you inspect an element in Chrome DevTools (Elements tab) and see:
#shadow-root (open)
...
…that means you’re viewing inside a shadow root. However, Chrome does not provide a built-in “Copy Shadow Selector” option. To automate discovering the path of nested shadow elements, we can use a small JavaScript helper in the Console that:
- Starts at the currently selected element in DevTools (
$0). - Climbs up the DOM tree, discovering each shadow host.
- Builds a shadow path that you can read and reuse.
Paste the following into the Console, select a target element in DevTools (so $0 references it), and run buildShadowPath($0):
(function buildShadowPath(el) {
if (!el) {
console.warn("No element provided.");
return;
}
const chain = [];
let current = el;
// We climb until there's no further shadow host.
while (current) {
const root = current.getRootNode();
const host = root.host;
if (!host) {
// We reached the document root (no more shadow). Stop.
break;
}
// Form a basic CSS piece for the host:
let selector = host.tagName.toLowerCase();
if (host.id) {
selector += `#${host.id}`;
} else if (host.classList && host.classList.length > 0) {
selector += '.' + [...host.classList].join('.');
}
chain.unshift(selector);
current = host;
}
// Join with ' >> shadowRoot >> ' to show each level
if (chain.length === 0) {
console.log("Element is not inside any shadow roots, or no valid host found.");
} else {
console.log("Shadow path:\n" + chain.join(" >> shadowRoot >> "));
}
})($0);Example output might look like:
Shadow path:
cp-app-launcher.cp-app..fs-mask >> shadowRoot >> cp-app#control >> shadowRoot >> cp-app-toolbar#menubar >> shadowRoot >> paper-icon-button >> shadowRoot >> iron-icon#icon
This tells you the exact “host chain” you need to traverse in order—from the outermost custom element to the innermost.
- Find the topmost custom element in the path using
document.querySelector(...). - Get its shadow root with
shadowHost.shadowRoot. - Inside that shadow root, use
.querySelector(...)to find the next custom element. - Repeat until you reach the final desired web component (like
cpsi-text-field). - If your final target is still a “native” element inside that last component (like
<input>), you also dolastComponent.shadowRoot.querySelector('input')to get that real DOM node.
- Custom Value Binding: Many frameworks (including Polymer, LitElement, Vaadin, etc.) store the user’s input in the custom element’s property (e.g.,
<cpsi-text-field>.value). If you only setmyInput.value = "Foo"on the native<input>, you may not trigger the custom element’s internal logic. The result is the framework never “sees” that username/password were filled. - Event-Based Logic: The button might remain disabled until an “input” event is fired on the custom element, or until certain property changes are detected. Simply setting
.valueon the raw<input>might not suffice. - Disabled State: The
<button>might remain disabled in markup even if you programmatically set.value. Without the right events or property changes, the framework won’t re-enable it.
Below is an actual JavaScript snippet you can run in the DevTools Console. It shows:
- Extracting top-level custom elements via
document.querySelector(...) - Accessing each
.shadowRoot - Setting the custom element’s
.value(and dispatching events) - Forcing the button to enable, if necessary
- Clicking the final button
(() => {
// 1. Top-level <cpsi-oauth-grant-app>
const grantApp = document.querySelector("cpsi-oauth-grant-app");
const grantAppShadow = grantApp.shadowRoot;
// 2. <cpsi-grant-login id="loginPage">
const grantLogin = grantAppShadow.querySelector("cpsi-grant-login#loginPage");
const grantLoginShadow = grantLogin.shadowRoot;
// 3. Grab <cpsi-text-field id="usernameInput"> (the custom element)
const usernameField = grantLoginShadow.querySelector("cpsi-text-field#usernameInput");
// Set the custom element’s `value` property:
usernameField.value = "username";
// Fire an 'input' or 'change' event so it knows about the update:
usernameField.dispatchEvent(new Event("input", { bubbles: true }));
// usernameField.dispatchEvent(new Event("change", { bubbles: true }));
// 4. Grab <cpsi-password-field id="passwordInput"> (the custom element)
const passwordField = grantLoginShadow.querySelector("cpsi-password-field#passwordInput");
passwordField.value = "Test@123";
passwordField.dispatchEvent(new Event("input", { bubbles: true }));
// 5. Force enable the button (if needed)
const affirmButton = grantLoginShadow.querySelector("cpsi-button.action.affirm");
affirmButton.removeAttribute("disabled");
const affirmButtonShadow = affirmButton.shadowRoot;
const actualButton = affirmButtonShadow.querySelector("button.btn");
actualButton.removeAttribute("disabled");
// 6. Click
actualButton.click();
})();usernameField.value = "u10380";– We set the property on the custom element, not just the native<input>.- Event Dispatch –
dispatchEvent(new Event("input", { bubbles: true }))is crucial if the custom element’s logic depends on user-typed changes. Some frameworks watch forchangeorblur. You may need to dispatch multiple events if one alone isn’t recognized. - Button Enable – We do
affirmButton.removeAttribute("disabled")because the form might otherwise remain disabled. If the component’s logic is correct, it should automatically enable the button once valid inputs are detected. But in some cases you have to force it if the standard event approach isn’t recognized by the component. - Click – Finally, we do
actualButton.click()on the native<button>inside the<cpsi-button>shadow root.
-
Nested Shadow Roots: If you have multiple levels of nesting, repeat the pattern:
document.querySelector().shadowRoot.querySelector()- Next
.shadowRoot.querySelector(), etc.
-
Selenium Python: In Selenium (Python), you cannot do
driver.find_element(...)directly through shadow roots. You need something like: example (1)def _login(self, username: str, password: str) -> bool: self._logger.info("Logging in to TrueBridge Web Portal (Approach #3)") try: driver = self._browser.get_driver() # 1. Directly return the <cpsi-grant-login#loginPage> element from the top-level shadow root. grant_login = driver.execute_script( """ const grantApp = document.querySelector('cpsi-oauth-grant-app'); return grantApp.shadowRoot.querySelector('cpsi-grant-login#loginPage'); """ ) # Wait for the element to be ready time.sleep(2) # 2. Return the username field element from the <cpsi-grant-login#loginPage>'s shadow root. username_field = driver.execute_script( """ return arguments[0].shadowRoot.querySelector('cpsi-text-field#usernameInput'); """, grant_login, ) # 3. Set the username value. driver.execute_script( """ arguments[0].value = arguments[1]; arguments[0].dispatchEvent(new Event('input', { bubbles: true })); """, username_field, username, ) # Wait for the element to be ready time.sleep(2) # 4. Return the password field element and set the password. password_field = driver.execute_script( """ return arguments[0].shadowRoot.querySelector('cpsi-password-field#passwordInput'); """, grant_login, ) # 5. Set the password value. driver.execute_script( """ arguments[0].value = arguments[1]; arguments[0].dispatchEvent(new Event('input', { bubbles: true })); """, password_field, password, ) # Wait for the element to be ready time.sleep(2) # 6. Return the affirm button and remove the 'disabled' attribute. affirm_button = driver.execute_script( """ const loginShadow = arguments[0].shadowRoot; const button = loginShadow.querySelector('cpsi-button.action.affirm'); button.removeAttribute('disabled'); return button; """, grant_login, ) time.sleep(1) # 7. Return the actual <button> inside the affirm button’s shadow root and enable it. actual_button = driver.execute_script( """ const affirmShadow = arguments[0].shadowRoot; const realButton = affirmShadow.querySelector('button.btn'); realButton.removeAttribute('disabled'); return realButton; """, affirm_button, )
example (2)
def _update_input_and_search(self, encounter_id: str) -> None: """ Navigates nested shadow roots to set the wmap input value and press Enter. Each step is broken out so you can inspect the returned elements for null. """ driver: WebDriver = self._browser.get_driver() script = """ const root1 = document.querySelector('cp-app-launcher'); const shadow1 = root1.shadowRoot; const root2 = shadow1.querySelector('cp-app#control'); const shadow2 = root2.shadowRoot; const root3 = shadow2.querySelector('cp-screen-viewport#mainpanel'); const shadow3 = root3.shadowRoot; const root4 = shadow3.querySelector('cp-screen#screen_4_0'); const shadow4 = root4.shadowRoot; const root5 = shadow4.querySelector('cp-clientware#cw'); const shadow5 = root5.shadowRoot; const root6 = shadow5.querySelector('clientware-component#cw'); const shadow6 = root6.shadowRoot; const root7 = shadow6.querySelector('clientware-wmap.wmap'); const shadow7 = root7.shadowRoot; return shadow7.querySelector('input#wmap-control-400'); """ wmap_input = driver.execute_script(script, encounter_id) # Use ActionChains to simulate real typing and pressing Enter actions = ActionChains(driver) actions.move_to_element(wmap_input).click() actions.send_keys(encounter_id) actions.send_keys(Keys.ENTER) actions.perform() time.sleep(1)
Brief Explanation:
Sometimes dispatching a synthetic'keydown'event isn’t recognized by the web component. Instead, returning the actual<input>from the nested shadow roots and using Selenium’sActionChainsto click, type, and press Enter often triggers the correct behavior. -
Event Names: Some frameworks might watch for
'change','input','keyup', or'blur'. If setting.valuealone doesn’t work, try dispatching additional events. -
Property vs. Attribute: Some custom elements might look at an attribute
<cpsi-text-field value="...">, others might rely on the property. Usually, you want the property. -
Use the “Shadow Path”: The example script that prints your shadow path helps you visually see each host you must step through. Reuse that information when writing your automation code.
When you simply used:
const nativeInput = document
.querySelector("cpsi-text-field#usernameInput")
.shadowRoot
.querySelector("input");
nativeInput.value = "u10380";
const button = document
.querySelector("cpsi-button.action.affirm")
.shadowRoot
.querySelector("button");
button.click();…you might have found the button remained disabled or the form complained that “username is blank.” This is because:
- The custom element
<cpsi-text-field>might not have registered that a value was entered (it listens tothis.value, not the internal<input>.value). - The button’s logic to enable itself probably depends on the component’s internal state. If that state never updates, the button stays disabled or triggers an error message.
- Shadow DOM encapsulation means you must pierce each shadow root.
- Use the script to build a shadow path from a selected element.
- Set values on custom elements (like
myField.value = "…") or at least ensure you dispatch the correct events on the native<input>. - If the button is disabled, either:
- Trigger the correct events so the framework enables it naturally, or
- Force-remove
disabledif needed (less ideal).
-
Logging Into a Shadow-Encapsulated Form
- Use the shadow path snippet to find the route to
<cpsi-text-field#usernameInput>and<cpsi-password-field#passwordInput>. - In your test code, set the custom element’s
.valueproperties, dispatchinputevents. - Confirm the button becomes enabled, click it.
- Use the shadow path snippet to find the route to
-
Filling a Multi-Level Nested Component
- Suppose you have
outer-component >> shadowRoot >> inner-component >> shadowRoot >> target-input. - In your console or Selenium script, repeat the pattern:
const outer = document.querySelector('outer-component'); const outerShadow = outer.shadowRoot; const inner = outerShadow.querySelector('inner-component'); const innerShadow = inner.shadowRoot; const target = innerShadow.querySelector('target-input'); target.value = 'Hello!'; target.dispatchEvent(new Event('input', { bubbles: true }));
- Suppose you have
-
Clicking a Shadow DOM Button
- Extract the path to the button’s custom element.
- Check if the custom element is disabled. If it is, see if you need to set some property or event to enable it.
- Grab the native
<button>inside that element’s shadow root. .click()it.
Automating websites that heavily rely on the Shadow DOM requires specialized techniques:
- Trace your shadow hosts (manually or via the snippet).
- Access each
.shadowRootin turn. - Use the custom element’s property and dispatch events to reflect real user interaction.
- Confirm any validation or enable/disable logic that the framework might enforce.
By following these steps, you’ll be able to:
- Quickly discover how to locate deeply nested shadow elements.
- Correctly set values and click elements without hitting the usual “form fields are blank” or “button stays disabled” pitfalls.
Use the included code snippets as a template for your own tests and scripts, whether you’re working in pure JavaScript, Selenium (Python), or another automation framework.