You open the browser, inspect the element you want to click, copy the selector — and Playwright says it can't find it. You paste it into the console: works fine. You add an explicit wait: still fails. Then you notice a #shadow-root node sitting between your locator and the element you want. Welcome to Shadow DOM.
Shadow DOM is the encapsulation mechanism behind web components. It creates a sub-tree of DOM nodes that is intentionally isolated from the main document. Standard CSS selectors, XPath, and traditional test tools can't cross that boundary. Playwright can — but only with the right approach. This guide covers everything from Playwright's auto-piercing behavior to deeply nested shadows in enterprise frameworks.
What This Guide Covers
- What Shadow DOM is and how it affects test selectors
- Playwright's automatic shadow-piercing behavior
- Locating elements with CSS, ARIA roles, and text
- The
>>combinator for nested shadow roots - Closed shadow roots — what they are and what to do
- Real-world examples: Salesforce, SAP Fiori, date pickers
- Using
page.evaluate()as a last resort - Debugging Shadow DOM step by step
1. What Is Shadow DOM?
Shadow DOM is a browser API that lets a component attach a hidden, scoped DOM tree to an element. CSS and JavaScript from the main document cannot reach inside it — that's the whole point. It's how web components like <video>, <input type="date">, and custom elements like <my-button> keep their internals private.
# Regular DOM — all nodes are reachable by CSS/XPath <div class="app"> <form> <input type="email"> ← reachable with input[type=email] </form> </div> # Shadow DOM — inner nodes are behind a shadow boundary <my-input> ← shadow host (reachable) #shadow-root (open) ← shadow boundary <div class="wrapper"> <input type="email"> ← NOT reachable with input[type=email] </div> from the main document (standard CSS) </my-input>
When you write document.querySelector('input[type=email]') in the browser console, it finds the regular DOM input immediately. It does not find the input inside <my-input>'s shadow root. That's by design — the component is hiding its internals. Test tools that rely on querySelector see the same restriction.
Open vs closed shadow roots
Shadow roots have a mode property set when created:
// Open — accessible via element.shadowRoot this.attachShadow({ mode: 'open' }); // Closed — element.shadowRoot returns null this.attachShadow({ mode: 'closed' });
- Open — the shadow root is accessible via
element.shadowRoot. Playwright can pierce open shadow roots automatically - Closed —
element.shadowRootreturnsnull. No external script — including Playwright — can access the shadow tree through the standard API. Closed shadow roots are rare in practice but exist in some browser-native elements
The vast majority of web components and design systems (Lit, Stencil, Shoelace, Salesforce LWC, SAP UI5) use open shadow roots. This guide focuses on open shadows unless stated otherwise.
2. Playwright's Automatic Shadow Piercing
Here's the good news: Playwright's CSS locators automatically pierce open shadow roots. You don't need a special API, a plugin, or a piercing combinator for most cases. Playwright's engine traverses shadow boundaries the same way it traverses regular DOM nodes.
// HTML structure: // <my-login> // #shadow-root // <input data-test="username"> // <button data-test="submit">Login</button> // </my-login> // ✅ Playwright finds these through the shadow boundary automatically await page.locator('[data-test="username"]').fill('user@example.com'); await page.locator('[data-test="submit"]').click(); // ✅ Semantic locators also pierce shadow roots await page.getByLabel('Email address').fill('user@example.com'); await page.getByRole('button', { name: 'Login' }).click(); // ✅ getByText, getByPlaceholder, getByTestId — all pierce await page.getByPlaceholder('Enter your email').fill('test@test.com');
The single most important rule: Playwright's CSS pierces open shadow roots. Playwright's XPath does not. If your tests use XPath and they fail on shadow DOM elements, switch to CSS or semantic locators. This alone resolves most Shadow DOM test failures.
What does NOT pierce shadow roots
page.locator('[data-test="x"]')page.getByRole('button')page.getByLabel('Email')page.getByText('Submit')page.getByTestId('submit')
page.locator('xpath=//input')page.locator('//button[text()="OK"]')document.querySelector() in evaluate$x() in DevTools consoleAny XPath expression
3. Scoping Locators to a Web Component
When auto-piercing returns too many results (multiple components on the page have the same internal element), scope your locator to the shadow host first.
// Two <my-card> components on the page — each has a "Buy" button // Auto-piercing finds both buttons — locator is ambiguous // ✅ Scope to the specific shadow host first const firstCard = page.locator('my-card').first(); await firstCard.getByRole('button', { name: 'Buy' }).click(); // ✅ Or scope by a unique host attribute const premiumCard = page.locator('my-card[data-plan="premium"]'); await premiumCard.getByRole('button', { name: 'Buy' }).click(); // ✅ Chain locator calls — each step narrows the scope const navMenu = page.locator('app-navigation'); const settingsLink = navMenu.getByRole('link', { name: 'Settings' }); await settingsLink.click();
This pattern — locate the shadow host, then locate the inner element — is the recommended approach for any component-based application. It's readable, resilient to DOM changes outside the component, and mirrors how users think about the UI ("the settings link inside the navigation menu").
4. Nested Shadow DOM
Enterprise applications — particularly Salesforce Lightning and SAP Fiori — nest shadow roots 3–5 levels deep. A button might live inside a shadow root, inside another shadow root, inside another. Playwright's auto-piercing handles this, but you need to understand when it fails.
# Salesforce Lightning — typical nesting depth <lightning-button> #shadow-root (open) <button-group> #shadow-root (open) <lightning-primitive-button> #shadow-root (open) <button type="button"> ← target element (3 levels deep) </button> </lightning-primitive-button>
Auto-piercing for nested shadows
// Playwright pierces ALL shadow roots in the chain automatically // This finds the button 3 levels deep without any special syntax await page.getByRole('button', { name: 'Save' }).click(); // Or by test ID if the inner element has one await page.getByTestId('save-btn').click();
The >> CSS combinator for explicit chaining
When auto-piercing isn't resolving correctly (usually due to ambiguous selectors across multiple nested shadows), use explicit chaining with the >> combinator:
// >> explicitly chains through shadow boundaries // useful when you need to scope precisely through multiple levels // Structure: // <lightning-record-form> // #shadow-root // <lightning-input> // #shadow-root // <input type="text"> await page .locator('lightning-record-form >> lightning-input >> input') .fill('John Smith'); // Or chain locator() calls (preferred — more readable) await page .locator('lightning-record-form') .locator('lightning-input') .locator('input') .fill('John Smith');
When to use explicit chaining: Auto-piercing works for the majority of shadow DOM cases. Use explicit chaining (>> or chained .locator()) when: (1) auto-piercing returns multiple matches across different shadow trees, (2) you need to be specific about which shadow host to enter, or (3) you're documenting the component hierarchy intentionally in your test for maintainability.
5. Real-World Shadow DOM Examples
Salesforce Lightning Web Components (LWC)
Salesforce LWC uses open shadow roots for every component. Modern Salesforce orgs (Spring '24+) use native shadow DOM. Older orgs use "synthetic shadow" — a polyfill that behaves slightly differently.
test('create a new contact record', async ({ page }) => { await page.goto('/lightning/o/Contact/new'); // Salesforce modal renders inside nested shadow roots // getByLabel pierces all shadow boundaries automatically await page.getByLabel('First Name').fill('Jane'); await page.getByLabel('Last Name').fill('Doe'); await page.getByLabel('Email').fill('jane.doe@example.com'); // Save button deep in lightning-record-edit-form shadow tree await page.getByRole('button', { name: 'Save' }).click(); // Wait for record to save and URL to update await page.waitForURL(/\/Contact\//); await expect(page.getByText('Jane Doe')).toBeVisible(); });
Shoelace / Web Component UI kits
Shoelace is a popular open-source web component library. Its components wrap native HTML elements inside shadow roots with consistent naming.
// Shoelace structure: // <sl-input label="Email"> // #shadow-root // <label>Email</label> // <input type="email" placeholder="..."> // </sl-input> test('submit contact form with Shoelace inputs', async ({ page }) => { await page.goto('/contact'); // getByLabel pierces sl-input's shadow root to find the inner <label> await page.getByLabel('Email').fill('hello@example.com'); await page.getByLabel('Message').fill('Hello from Playwright!'); // sl-button wraps a native button await page.getByRole('button', { name: 'Send Message' }).click(); await expect(page.getByText('Message sent!')).toBeVisible(); });
Native browser date picker (<input type="date">)
The browser's native date picker renders in a shadow root that is implementation-specific per browser. Playwright handles it differently from custom component shadows:
// ✅ fill() sets the value directly — bypasses the shadow UI entirely // Works reliably across Chromium, Firefox, WebKit await page.getByLabel('Date of Birth').fill('1990-06-15'); // ❌ Don't try to click the calendar UI — it's browser-specific shadow DOM // and behaves differently across Chromium, Firefox, and Safari // await page.locator('.calendar-day-15').click(); // Unreliable // ✅ Or use evaluate() to set value and dispatch change event await page.evaluate(() => { const input = document.querySelector('input[type=date]') as HTMLInputElement; input.value = '1990-06-15'; input.dispatchEvent(new Event('change', { bubbles: true })); });
Custom date picker components
Third-party date pickers (Flatpickr, Pikaday, custom LitElement components) vary widely. The safest strategy:
// Strategy 1: fill the hidden input if one exists (fastest) await page.locator('date-picker input[type="hidden"]').evaluate( (el, val) => { (el as HTMLInputElement).value = val; }, '2026-09-02' ); // Strategy 2: fill the visible text input and press Enter await page.locator('date-picker').getByRole('textbox').fill('09/02/2026'); await page.keyboard.press('Enter'); // Strategy 3: click through the calendar UI (most realistic, slowest) // Only use if the form validates that the calendar was interacted with await page.locator('date-picker').getByRole('button', { name: 'Open calendar' }).click(); await page.locator('date-picker').getByRole('button', { name: '2' }).click();
6. Closed Shadow Roots
Closed shadow roots are the genuine hard wall. When attachShadow({ mode: 'closed' }) is used, element.shadowRoot returns null — no external script can access the internal DOM, including Playwright's automation engine.
In practice, closed shadow roots are rare. Most applications use open shadow roots. The browser's own internal elements (<video> controls, <input type="color">, some Chrome extension components) use closed shadow, but you rarely need to test these directly.
How to detect a closed shadow root
// Run in DevTools console to check the mode document.querySelector('my-component').shadowRoot // Returns: ShadowRoot {...} → open (Playwright can pierce) // Returns: null → closed (Playwright cannot pierce)
Workarounds for closed shadow roots
There is no clean solution for closed shadow roots — the API intentionally prevents access. These are your options, ordered by preference:
- Ask the development team to add test hooks —
data-testidattributes on the shadow host, or a public API method on the component that exposes the internal state you need to assert. This is the correct solution - Test at the component level — if the component is yours, write unit tests using the component's testing framework (Lit, Stencil) where you have access to the shadow root directly. Playwright tests the integrated application; unit tests cover the component internals
- Monkey-patch
attachShadowbefore the page loads — intercept the shadow creation to force open mode. This is a testing-only hack and should only be used when options 1 and 2 aren't available
// Add this to your test setup — intercepts shadow creation // Forces all closed shadows to open mode // Only use when you control the test environment and cannot modify the component await page.addInitScript(() => { const orig = Element.prototype.attachShadow; Element.prototype.attachShadow = function(init) { return orig.call(this, { ...init, mode: 'open' }); }; });
Security warning: The monkey-patch above makes closed shadow roots open in your test environment. Never ship this code to production — it bypasses the component's intended encapsulation. Only use it in test setup scripts, committed to your test helpers, not to your application code.
7. Using page.evaluate() for Complex Cases
When Playwright's locator API can't reach an element (closed shadow, deeply non-standard structure, or you need to read internal state rather than interact), page.evaluate() lets you run JavaScript directly in the browser context where shadow roots are accessible via .shadowRoot.
// Read a value from inside a shadow root const inputValue = await page.evaluate(() => { const host = document.querySelector('my-input'); const input = host?.shadowRoot?.querySelector('input'); return input?.value; }); expect(inputValue).toBe('expected@email.com'); // Traverse nested shadow roots const buttonText = await page.evaluate(() => { const form = document.querySelector('my-form'); const actions = form?.shadowRoot?.querySelector('my-actions'); const btn = actions?.shadowRoot?.querySelector('button[type=submit]'); return btn?.textContent?.trim(); }); expect(buttonText).toBe('Save'); // Trigger an event inside a shadow root await page.evaluate(() => { const host = document.querySelector('my-toggle'); const checkbox = host?.shadowRoot?.querySelector('input[type=checkbox]'); checkbox?.click(); });
Use evaluate() for reads, not interactions. page.evaluate() bypasses Playwright's auto-waiting, actionability checks, and trace recording. An interaction triggered via evaluate() won't appear in Playwright's Trace Viewer as a user action — it looks like a raw script execution. Reserve it for assertions on internal state, not for clicks and form fills that Playwright's locator API can handle.
8. Shadow DOM and AI Test Generation
Shadow DOM is called "the silent killer of AI testing in 2026" for a reason. When AI tools generate Playwright tests from a page description or screenshot, they can't see inside shadow boundaries — they generate locators for the shadow host, not the internal elements. The test fails the moment it tries to interact with the actual input or button.
Why AI tools struggle with Shadow DOM
- GitHub Copilot generates locators based on what you describe — it can't inspect the live DOM, so it invents plausible selectors that may point to the shadow host rather than the encapsulated element
- Playwright Codegen handles Shadow DOM well when recording because it observes actual user clicks, but the generated CSS may be overly specific
- Claude AI with MCP Server can navigate the live page and inspect shadow roots directly, making it the most reliable AI tool for Shadow DOM test generation — it sees the actual DOM tree including shadow content
Debugging AI-generated tests that fail on Shadow DOM
If you receive a test from an AI tool and it fails with "element not found" on a shadow-heavy page:
- Open the page in Chrome DevTools
- Go to Settings → Preferences → Elements and enable Show user agent shadow DOM
- Inspect the failing element — look for
#shadow-rootnodes in the tree - Replace XPath locators with CSS or semantic locators
- Run with
page.pause()and use the Playwright Inspector to verify what the locator resolves to
9. Debugging Shadow DOM Step by Step
When a locator fails on a Shadow DOM element, follow this diagnostic sequence:
Step 1 — Identify the shadow boundary
// Find all elements with shadow roots on the page [...document.querySelectorAll('*')] .filter(el => el.shadowRoot) .map(el => el.tagName.toLowerCase()) // Check if a specific element has a shadow root document.querySelector('my-component').shadowRoot // ShadowRoot = open, null = closed
Step 2 — Test locators in the console
// Standard querySelector — does NOT cross shadow boundary document.querySelector('my-input input') // → null (fails) // Manual shadow traversal — does cross document.querySelector('my-input').shadowRoot.querySelector('input') // → <input> // In Playwright, the locator API handles this automatically: // page.locator('my-input input') finds the input through the shadow root
Step 3 — Use the Playwright Inspector
test('debug shadow DOM locator', async ({ page }) => { await page.goto('https://your-app.com'); // Pause here — Playwright Inspector opens // Hover over elements to see suggested locators // Test locators in the Inspector's input field await page.pause(); // After finding the right locator, replace pause() with the actual test });
Step 4 — Check the Trace Viewer
Enable tracing in playwright.config.ts (trace: 'on') and run your failing test. Open the trace with npx playwright show-trace trace.zip. The Trace Viewer shows a DOM snapshot at each step — including shadow root contents — so you can see exactly what the page looked like when the locator was evaluated.
use: { // Capture trace on every run for debugging (use 'on-first-retry' in CI) trace: 'on', }
10. Best Practices
getByRole(), getByLabel(), getByText(), and getByPlaceholder() all pierce shadow roots automatically and are tied to accessibility attributes rather than implementation details. A well-written web component that exposes correct ARIA labels is testable with semantic locators without any shadow-specific knowledge.
XPath cannot cross shadow boundaries — ever. If your application uses web components and your existing tests use XPath, migrating to CSS or semantic locators is non-negotiable. This is the #1 cause of "element not found" errors on shadow-heavy pages. The migration from XPath to Playwright's locator API also improves test stability generally — see our locators guide.
Work with your development team to add data-test attributes on web component host elements. Even if you can't control what's inside the shadow root, a stable data-test="login-form" on the <my-login> host gives you a reliable starting point for scoping your locators. For components your team builds, also add data-test attributes to key internal elements.
On pages with multiple instances of the same component, auto-piercing may match the wrong instance. Always scope: page.locator('my-card[data-product="123"]').getByRole('button', { name: 'Buy' }) rather than page.getByRole('button', { name: 'Buy' }) when ambiguity is possible. This is faster to debug and communicates intent clearly to future readers.
page.evaluate() is invaluable for reading shadow root state — checking an input's value, reading a component's internal property, or verifying that a shadow-encapsulated checkbox is checked. Reserve it for these read operations. For clicking, filling, and keyboard interactions, always use Playwright's locator API — it has built-in waiting, retry, and trace recording that evaluate() bypasses.
FAQ
Does Playwright automatically pierce Shadow DOM?
Yes — for open shadow roots. Playwright's CSS locators and all semantic locators (getByRole, getByLabel, getByText) automatically traverse shadow boundaries. XPath does not. Closed shadow roots (mode: 'closed') cannot be pierced by any external tool without a monkey-patch.
Why can't Playwright find elements inside Shadow DOM?
The most common causes: (1) you're using XPath — switch to CSS or semantic locators, (2) the shadow root is closed — element.shadowRoot returns null, (3) the element is inside nested shadow roots and the auto-piercing is finding the wrong instance — scope to the shadow host first, (4) the shadow root is created asynchronously — add a wait before locating.
Can Playwright test Salesforce Lightning components?
Yes. Salesforce Lightning uses open shadow roots. Playwright's auto-piercing handles most scenarios. Use getByLabel() for form fields and getByRole('button', { name: '...' }) for buttons — these pierce the nested LWC shadow tree automatically. For deeply nested or ambiguous selectors, chain .locator() calls to scope through each shadow level.
Does getByRole work inside Shadow DOM?
Yes. All Playwright semantic locators — getByRole, getByLabel, getByText, getByPlaceholder, getByTestId — pierce open shadow roots. This is the recommended approach for web component testing because it tests the component's public accessibility interface, not its internal implementation.
How do I debug Shadow DOM elements in Playwright?
Three tools: (1) page.pause() opens the Playwright Inspector — hover elements to see locator suggestions, (2) Chrome DevTools with "Show user agent shadow DOM" enabled reveals all shadow roots in the Elements panel, (3) Playwright Trace Viewer (npx playwright show-trace) shows DOM snapshots at each test step including shadow root contents.
What is the >> combinator in Playwright?
The >> combinator explicitly chains through shadow boundaries in a CSS selector: page.locator('lightning-input >> input') enters the lightning-input shadow root and finds the input inside. It's equivalent to chaining .locator() calls and is useful when you want to document the shadow traversal path explicitly in your locator string.
Playwright + Claude AI Course
Master Playwright Locators — Including Shadow DOM, Web Components, and AI Generation
Shadow DOM is one chapter. The full course covers every locator strategy, Page Object Model, API testing, Claude AI generating tests from the live DOM, and a complete framework built on a real e-commerce project. Everything AI tools get wrong about locators — you'll get right.
- Shadow DOM, iframes, web components — all covered
- Claude AI reads the real DOM to generate accurate locators
- Complete Playwright TypeScript framework from scratch
- Real e-commerce project — portfolio-ready from day one