Skip to content

Instantly share code, notes, and snippets.

@braxton-bell
Last active May 17, 2026 14:39
Show Gist options
  • Select an option

  • Save braxton-bell/b2930d756c7a75abb873d7bc811f1122 to your computer and use it in GitHub Desktop.

Select an option

Save braxton-bell/b2930d756c7a75abb873d7bc811f1122 to your computer and use it in GitHub Desktop.

How to Interact with Shadow DOM Elements: A Developer’s Guide to Automation

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:

  1. Why Shadow DOM poses unique challenges for automation.
  2. How to generate a shadow path for nested elements.
  3. Why setting values and clicking buttons in Shadow DOM sometimes fails if done “the usual way.”
  4. 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.


1. Understanding the Shadow DOM and Why It’s Tricky

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.

Common Automation Roadblocks

  1. 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 .shadowRoot first.
  2. Nested Shadow Roots: Many web components are themselves nested in other shadow roots, creating multi-level shadow hierarchies.
  3. 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.
  4. 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.

2. Extracting a Shadow Path From Chrome DevTools

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:

  1. Starts at the currently selected element in DevTools ($0).
  2. Climbs up the DOM tree, discovering each shadow host.
  3. Builds a shadow path that you can read and reuse.

The Shadow-Path Extraction Script

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.


3. Using the Shadow Path to Automate Interactions

The General Approach

  1. Find the topmost custom element in the path using document.querySelector(...).
  2. Get its shadow root with shadowHost.shadowRoot.
  3. Inside that shadow root, use .querySelector(...) to find the next custom element.
  4. Repeat until you reach the final desired web component (like cpsi-text-field).
  5. If your final target is still a “native” element inside that last component (like <input>), you also do lastComponent.shadowRoot.querySelector('input') to get that real DOM node.

Why the Usual “.value” or “.click()” Might Fail

  • 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 set myInput.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 .value on 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.

4. Example of a Working Shadow-DOM Interaction

Below is an actual JavaScript snippet you can run in the DevTools Console. It shows:

  1. Extracting top-level custom elements via document.querySelector(...)
  2. Accessing each .shadowRoot
  3. Setting the custom element’s .value (and dispatching events)
  4. Forcing the button to enable, if necessary
  5. 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();
})();

Explanation & Important Details

  1. usernameField.value = "u10380"; – We set the property on the custom element, not just the native <input>.
  2. Event DispatchdispatchEvent(new Event("input", { bubbles: true })) is crucial if the custom element’s logic depends on user-typed changes. Some frameworks watch for change or blur. You may need to dispatch multiple events if one alone isn’t recognized.
  3. 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.
  4. Click – Finally, we do actualButton.click() on the native <button> inside the <cpsi-button> shadow root.

5. Common Variations and Tips

  1. Nested Shadow Roots: If you have multiple levels of nesting, repeat the pattern:

    • document.querySelector()
    • .shadowRoot.querySelector()
    • Next .shadowRoot.querySelector(), etc.
  2. 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’s ActionChains to click, type, and press Enter often triggers the correct behavior.

  3. Event Names: Some frameworks might watch for 'change', 'input', 'keyup', or 'blur'. If setting .value alone doesn’t work, try dispatching additional events.

  4. 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.

  5. 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.


6. Why the Click-Button Action Didn’t Work Originally

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:

  1. The custom element <cpsi-text-field> might not have registered that a value was entered (it listens to this.value, not the internal <input>.value).
  2. 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.

7. Final Takeaways

  • 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:
    1. Trigger the correct events so the framework enables it naturally, or
    2. Force-remove disabled if needed (less ideal).

8. Example Use Cases

  1. 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 .value properties, dispatch input events.
    • Confirm the button becomes enabled, click it.
  2. 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 }));
  3. 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.

Conclusion

Automating websites that heavily rely on the Shadow DOM requires specialized techniques:

  • Trace your shadow hosts (manually or via the snippet).
  • Access each .shadowRoot in 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:

  1. Quickly discover how to locate deeply nested shadow elements.
  2. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment