How to Automate Shadow DOM in Selenium and Playwright
Practice shadow DOM automation — piercing open shadow roots, scoping to shadow hosts, nested shadow trees, forms inside shadow DOM, and evaluating closed shadow roots in Playwright, Selenium & Cypress.
Interactive Scenarios
Click the button rendered inside an open shadow root. Playwright auto-pierces — just chain locators.
[data-testid="shadow-host-basic"]shadow: openThree shadow hosts share data-testid="shadow-host". Scope to the correct one using data-host-id.
[data-host-id="host-1"]shadow: open[data-host-id="host-2"]shadow: open[data-host-id="host-3"]shadow: open[data-testid="shadow-outer-host"]shadow: openInner host has class="shadow-inner-host" and data-inner="true" but no testid. Playwright pierces both levels automatically.
Input and select have id and name but no data-testid. Use getByRole or getByLabel inside the shadow scope.
[data-testid="shadow-form-host"]shadow: openContent loads after ~1.5 s. The revealed button has aria-label="Trigger Shadow Action" but no testid. Spinner has role="status" only.
[data-testid="shadow-dynamic-host"]shadow: openloading…Input has only name="evalCode". The button has no attributes at all— just text "Execute". Standard locators cannot target these. Use page.evaluate().
getByTestId, getByRole, getByLabel) will NOT find elements with no stable attributes inside shadow DOM. You must use page.evaluate() or js.executeScript().[data-testid="shadow-eval-host"]shadow: open| ID | Scenario | Type | Priority | |
|---|---|---|---|---|
| SD_001 | Click button inside basic open shadow root Expected: Button inside shadow DOM is clicked, click count increments ✅ positivehigh | positive | high | |
| SD_002 | Assert element inside shadow DOM by data-testid Expected: Shadow button is visible and enabled inside open shadow root ✅ positivehigh | positive | high | |
| SD_003 | Assert click count span (no testid — locate by id inside shadow) Expected: Click count span updates correctly after each click ✅ positivemedium | positive | medium | |
| SD_004 | Scope to correct shadow host by data-host-id Expected: Only host-2 is activated, other hosts remain unchanged ✅ positivehigh | positive | high | |
| SD_005 | Distinguish between multiple shadow hosts with same testid Expected: Scoped locator targets only the correct shadow host ✅ positivehigh | positive | high | |
| SD_006 | Selenium getShadowRoot() to find element inside shadow Expected: Shadow button found and clicked via Selenium getShadowRoot() API ✅ positivehigh | positive | high | |
| SD_007 | Nested shadow DOM — Playwright auto-pierces two levels Expected: Button inside inner shadow root (2 levels deep) is clicked ✅ positivehigh | positive | high | |
| SD_008 | Nested shadow DOM — Selenium getShadowRoot() chained Expected: Inner shadow button clicked via chained getShadowRoot() calls ✅ positivemedium | positive | medium | |
| SD_009 | Fill form input inside shadow DOM — getByLabel (no testid on input) Expected: Form input fills correctly using label-based locator inside shadow ✅ positivehigh | positive | high | |
| SD_010 | Select option inside shadow DOM — getByLabel Expected: Select element inside shadow responds to selection ✅ positivemedium | positive | medium | |
| SD_011 | Submit form inside shadow DOM and assert result Expected: Shadow form submits and confirmation message appears ✅ positivehigh | positive | high | |
| SD_012 | Wait for dynamic content inside shadow DOM Expected: Dynamic shadow button becomes visible after loading delay ✅ positivehigh | positive | high | |
| SD_013 | page.evaluate() to click button with no stable attributes Expected: Shadow button with no testid/aria-label is clicked via JS evaluate ✅ positivehigh | positive | high | |
| SD_014 | Verify standard locators fail on closed shadow root; use evaluate Expected: Standard DOM locators return no element; evaluate succeeds ❌ negativemedium | negative | medium |
Shadow DOM encapsulates a subtree of DOM nodes inside a host element, hiding its internals from the main document. This is the foundation of Web Components. Automation tools handle open shadow roots differently: Playwright auto-pierces, Selenium 4 provides getShadowRoot(), and Cypress uses .shadow(). Closed shadow roots require JavaScript evaluate() to interact with.
Playwright automatically pierces open shadow roots when you chain locators. Selenium 4 introduced the getShadowRoot() method for Java, Python, and C#. Cypress exposes the .shadow() command for traversing into shadow DOM.
// Playwright auto-pierces open shadow DOM — just chain locators
const host = page.locator('[data-testid="shadow-host-basic"]');
// Click button inside shadow root
await host.locator('[data-testid="shadow-btn-basic"]').click();
// Assert count span (only has id, no testid)
await expect(host.locator('#shadow-click-count')).toHaveText('1');
// Alternative: getByRole/getByLabel also works inside shadow
await page.locator('[data-testid="shadow-form-host"]')
.getByRole('textbox', { name: 'Full name' })
.fill('Ada Lovelace');Nested shadow trees require chaining getShadowRoot() calls in Selenium or chaining locators in Playwright. Each host element is a boundary. Playwright handles any depth automatically; Selenium requires one getShadowRoot() call per nesting level.
// Playwright — auto-pierces any depth of shadow DOM
// Outer host → inner shadow host → button
await page
.locator('[data-testid="shadow-outer-host"]') // outer host
.locator('[data-inner="true"]') // inner host (no testid)
.locator('[data-testid="shadow-inner-btn"]') // button inside inner shadow
.click();
// Playwright resolves each locator within its shadow scope automaticallyWhen elements inside shadow DOM have no stable attributes (no testid, no aria-label), you must use JavaScript evaluation to interact with them. page.evaluate() in Playwright gives direct DOM access. Selenium uses JavascriptExecutor for the same purpose.
// Playwright evaluate() — when elements have no stable selectors
await page.evaluate(() => {
const host = document.querySelector('[data-testid="shadow-eval-host"]');
const shadow = host?.shadowRoot;
if (!shadow) throw new Error('Shadow root not accessible');
// Set input value (input has only name="evalCode", no id/testid)
const input = shadow.querySelector<HTMLInputElement>('input[name="evalCode"]');
if (input) input.value = 'automation-test';
// Click the nameless button (only visible text "Execute")
const btn = shadow.querySelector<HTMLButtonElement>('button');
btn?.click();
});
// Verify result via a data attribute set by the shadow script
const host = page.locator('[data-testid="shadow-eval-host"]');
await expect(host).toHaveAttribute('data-eval-done', 'true');Closed shadow roots (mode: 'closed') prevent external access to the shadow root via .shadowRoot. Standard locators cannot pierce closed shadow. Only internally stored references (via attachShadow return value) work from JS. In practice, automation usually interacts with open shadow roots.
// Closed shadow roots — .shadowRoot returns null externally
const isNull = await page.evaluate(() => {
const host = document.querySelector('[data-testid="shadow-eval-host"]');
return host?.shadowRoot === null; // true for mode: 'closed'
});
// The only way in: evaluate with an internally stored reference
// (set up when the shadow was created)
await page.evaluate(() => {
// closedShadowRef is a variable kept inside the page scope
// set during attachShadow({ mode: 'closed' })
if (typeof window.__closedShadow !== 'undefined') {
window.__closedShadow.querySelector('button')?.click();
}
});
// In practice: real-world components rarely use closed mode.
// Open shadow roots are the automation-realistic target.| Action | Selenium | Playwright JS | Playwright PY | Cypress |
|---|---|---|---|---|
| Pierce open shadow | element.getShadowRoot() | locator().locator() (auto) | locator().locator() (auto) | .shadow().find() |
| Nested shadow pierce | getShadowRoot().findElement().getShadowRoot() | Automatic — no extra calls | Automatic — no extra calls | .shadow().find(host).shadow() |
| Role / label inside shadow | shadowRoot.findElement(By.xpath) — ⚠ XPath blocked | locator().getByRole() / getByLabel() | locator().get_by_role() / get_by_label() | .shadow().find('[aria-label=...]') |
| No stable attributes (evaluate) | js.executeScript("el.shadowRoot.querySelector(...)") | page.evaluate(() => el.shadowRoot...) | page.evaluate("...") | .then(el => el[0].shadowRoot.querySelector(...)) |
| Closed shadow root | getShadowRoot() throws — use JS executor | evaluate() with internally stored ref | evaluate() with internally stored ref | .then() with window ref (if exposed) |