Web components use the shadow DOM to encapsulate their internals. A custom element like <user-card> attaches a shadow root, renders its own markup and styles inside it, and that subtree stays isolated from the rest of the page: outside CSS does not leak in, and document.querySelector does not see in.
Shadow roots come in two modes. An open root, created with attachShadow({ mode: 'open' }), is inspectable from outside through the element's shadowRoot property. A closed root hides even that. Nearly all component libraries ship open roots, and the distinction decides everything about testability, so it is worth checking before you write a single locator.
Playwright's built-in locators and its CSS engine treat open shadow trees as part of the page. getByRole, getByText, getByLabel, getByTestId, and plain CSS through locator() all descend into open shadow roots automatically, at any depth. There is no flag to set, no special syntax like the pierce/ prefixes other tools use, and nothing to configure.
In practice this means you write locators exactly as the locators guide teaches, and whether the target element sits in the light DOM or three shadow roots deep is invisible to your test. This works because getByRole queries the accessibility tree, which the browser assembles across shadow boundaries, and because Playwright's CSS engine was built to traverse shadow roots rather than stopping at them.
Take a small web component with an open shadow root:
<script>
class NewsletterSignup extends HTMLElement {
connectedCallback() {
const root = this.attachShadow({ mode: 'open' });
root.innerHTML = `
<form>
<label for="email">Email address</label>
<input id="email" type="email" />
<button type="submit">Subscribe</button>
<p class="status" hidden>Subscribed</p>
</form>
`;
}
}
customElements.define('newsletter-signup', NewsletterSignup);
</script>
<newsletter-signup></newsletter-signup>Everything inside the form lives in the shadow tree. The test does not mention that fact anywhere:
test('subscribes through the web component', async ({ page }) => {
await page.goto('/');
await page.getByLabel('Email address').fill('leo@example.com');
await page.getByRole('button', { name: 'Subscribe' }).click();
await expect(page.getByText('Subscribed')).toBeVisible();
});CSS works the same way. A selector like page.locator('newsletter-signup input#email') matches even though the input is behind the shadow boundary, which would fail in document.querySelector. Scoping through the host element like this is a good habit when a page contains several instances of the same component.
Design systems nest components: a <checkout-form> renders an <address-fieldset> which renders a <text-input>, each with its own shadow root. Playwright descends through all of them:
await page.getByRole('textbox', { name: 'Postal code' }).fill('75001');No chaining, no per-level hops. If you want to scope the search for precision, chain locators on the host elements the same way you would chain any parent and child, and the shadow boundaries in between stay irrelevant.
Two things stop at the boundary.
XPath does not pierce shadow roots. An expression like page.locator('xpath=//button[text()="Subscribe"]') evaluates against the main document, stops at every shadow boundary, and matches nothing, with no error explaining why. This is the sharpest edge in the whole topic, because the selector looks valid and the element is plainly visible. If a suite inherited XPath selectors and a page adopts web components, those selectors go dark. The fix is to rewrite them as getByRole or CSS, which you should prefer anyway.
Closed shadow roots are unreachable by design. attachShadow({ mode: 'closed' }) withholds the shadowRoot reference from every outside script, Playwright included. No locator strategy gets in. Closed roots are rare in component libraries precisely because they hurt testability, but you will meet them in some third-party widgets.
You are probably testing shadow DOM already without noticing. Salesforce Lightning renders its whole UI as Lightning Web Components, each with a shadow root, and automating any Salesforce screen means piercing them constantly. Design system libraries built on web components, such as Shoelace, Ionic, and FAST, put every button and field behind a shadow root. Date pickers and other complex inputs often ship as web components too, and the date picker guide applies unchanged when the picker happens to use one. The <video> player controls in most browsers are a closed shadow tree, which is why nobody clicks the native play button in tests.
The practical takeaway: when a page behaves like an app framework but querySelector in the console finds nothing, suspect web components, and know that your Playwright locators were already handling it.
Web-first assertions run on locators, and since locators pierce open roots, assertions do too:
await expect(page.getByRole('button', { name: 'Subscribe' })).toBeEnabled();
await expect(page.locator('newsletter-signup .status')).toHaveText('Subscribed');Retry behavior, soft assertions, and toHaveCount all work unchanged. There is no shadow-DOM-specific assertion API because none is needed.
When a locator misbehaves, confirm where the element actually lives. Right-click it, choose Inspect, and look at its ancestors in the Elements panel. A shadow tree appears under its host element behind a #shadow-root (open) or #shadow-root (closed) marker.
The marker answers both questions at once: whether you are dealing with shadow DOM at all, and whether the root is open. You can also ask in the console: document.querySelector('newsletter-signup').shadowRoot returns the root for an open tree and null for a closed one. If DevTools hides the markers, enable "Show user agent shadow DOM" in the Elements panel settings to see browser-internal trees too.
Component libraries expose customization through slots. The markup you place between a component's tags is projected into its shadow tree for rendering, but it remains part of the light DOM, owned by your page:
<confirm-dialog>
<button slot="confirm">Yes, delete it</button>
</confirm-dialog>That button is your element, not the component's. Locators find it without any piercing involved, and getByRole('button', { name: 'Yes, delete it' }) resolves it as ordinary page content. This distinction matters when a component has a closed root but slotted content: the internals are sealed, while everything you slotted in stays reachable. Similarly, CSS shadow parts, the ::part() mechanism, exist for styling rather than locating, so target the element by role or text as usual.
If the element you want sits behind a closed root, stop trying to reach it and test one level up. Interact with the component through what it exposes publicly: set its attributes, listen for the events it emits with page.evaluate(), or drive the user flow that surrounds it and assert on the visible outcome elsewhere on the page. A payment widget with sealed internals still produces a confirmation state in your app, and that state is what your users care about anyway. When even that is impossible, raise it with the component's authors, since a component that cannot be tested from outside is a defect worth reporting.
Component library upgrades reshuffle shadow internals far more often than they change roles and labels, which is exactly why role-based locators survive them. Hyperping Browser Checks run those same Playwright scripts against your production app around the clock, so a component update that breaks a real flow pages you instead of a user.