Piercing Nested Shadow DOM Components
Web-component frameworks like Lit and Stencil compose UIs from custom elements that each hide their internals behind a shadow root, and those components nest — a <my-dialog> containing a <my-form> containing a <my-input>, three shadow boundaries deep. A single CSS selector cannot cross those boundaries, and the old >>> and /deep/ combinators that once could have been removed from the platform. Playwright pierces open shadow roots automatically when you chain locators, and this page shows how to traverse arbitrarily deep nesting, why the deep combinators are gone, and what to do when a root is closed.
Root cause: a CSS selector cannot cross a shadow boundary
A shadow root is an encapsulation boundary by design — styles and selectors from the document do not reach inside, and a selector evaluated against the light DOM stops at the host element. Browsers once offered >>> (shadow-piercing descendant) and /deep/ to defeat that, but both were removed from the CSS specification and from engines because they broke the encapsulation guarantee the standard exists to provide. Playwright solves this differently: its locator engine automatically descends into open shadow roots at each step of a chain, so traversal is a property of chaining, not of a special combinator. This builds on Shadow DOM Traversal, under Reliable Selector Strategies for Playwright.
Minimal reproducible example
A dialog component nests a form, which nests a custom input. The chain below crosses all three open shadow roots to reach the native <input> inside the innermost component.
import { test, expect } from '@playwright/test';
test('fills an input three shadow roots deep', async ({ page }) => {
await page.goto('/settings');
// Each .locator() / role step descends through one open shadow root.
// Playwright pierces open roots automatically — no >>> combinator needed.
const dialog = page.locator('my-dialog'); // host in the light DOM
const form = dialog.locator('my-form'); // crosses dialog's shadow root
const field = form.getByRole('textbox', { name: 'Email' }); // crosses form + input roots
// Role queries also see into open shadow roots, which is the resilient choice.
await field.fill('user@example.com');
await expect(field).toHaveValue('user@example.com');
// A flat CSS selector cannot do this — it would never reach past my-dialog.
// page.locator('my-dialog input') // <-- matches nothing across boundaries
});
Step-by-step fix
- Chain one locator per logical layer. Start from the outermost custom element in the light DOM, then call
.locator()or agetBy*method for each nested component. Each step crosses one open shadow boundary, so the chain mirrors the component tree. - Prefer role and text queries inside the chain. Use
getByRole()andgetByText()for the final target rather than internal class names; these see into open shadow roots and survive a component's internal restyle, the same principle as in Automating Shadow DOM Elements with Playwright. - Do not write
>>>or/deep/. Those combinators no longer exist in the platform; a selector containing them will not match. Express depth through chaining instead, which is the supported mechanism. - Let auto-piercing handle open roots. A single
page.locator('my-input')already descends through any open shadow roots above it, so you only need explicit steps when you must disambiguate between repeated components or scope to a specific branch. - Handle closed roots with a framework hook. A closed shadow root is invisible to every locator. Configure the component to use
mode: 'open'in test builds, or expose a stable property and reach the inner node withpage.evaluate()against the host's exposed reference. - Scope to disambiguate repeats. When several
my-inputinstances exist, anchor the chain on a labeled ancestor (getByRole('group', { name: 'Billing' })) before the final step so the locator resolves to exactly one element.
Troubleshooting variants
The locator chain matches nothing past the first component
The boundary you are crossing is a closed shadow root, which no selector can pierce. Check the component definition for attachShadow({ mode: 'closed' }); switch it to open for test builds, or use a vendor-provided test hook. Lit and Stencil default to open roots, so if you authored the component this is usually a one-line change in the build configuration.
Chain works in Chromium but fails in WebKit or Firefox
Shadow DOM support is consistent across engines for open roots, so a cross-browser difference usually means a timing gap: the inner component upgraded later in one engine. Wait on the host with await expect(dialog).toBeVisible() before chaining inward, and avoid asserting before the custom element has registered. Synchronization patterns are covered in Handling Dynamic Content.
Slotted content cannot be found inside the component
Content projected through a <slot> lives in the light DOM of the host, not inside the shadow root, so chaining deeper misses it. Query slotted nodes from the host's light-DOM scope rather than from inside the component's shadow root, since the slot only renders them in place without moving them across the boundary.
Verification
Confirm the traversal three ways. First, assert on the final element's state (toHaveValue, toBeVisible) after the action — a chain that failed to pierce would have thrown a timeout, so a passing assertion proves every boundary was crossed. Second, run npx playwright codegen and click the deep element; the generated chain shows Playwright's own piercing path and validates your layering. Third, inspect the locator resolution in the Playwright Trace Viewer, where each chain step is recorded so you can see exactly which shadow root each segment entered.
Frequently Asked Questions
Why doesn't >>> or /deep/ work anymore?
Both shadow-piercing combinators were removed from the CSS specification and from browser engines because they broke the encapsulation that shadow DOM exists to provide. Playwright replaces them by descending into open shadow roots automatically at every step of a locator chain, so you express depth through chaining instead.
Do I need a special selector to enter a shadow root in Playwright?
No. Playwright's locator engine pierces open shadow roots automatically, so an ordinary page.locator(), getByRole(), or getByText() already sees inside them. You only chain explicit steps to disambiguate repeated components or to scope to a particular branch of the tree.
Can Playwright reach into a closed shadow root?
Not through normal locators — a closed root is hidden from the platform entirely. Configure the component to use open mode in test builds, or expose a reference on the host element and reach the inner node with page.evaluate() against that reference.