Playwright architecture, selector reliability, and advanced interaction patterns.

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.

Chaining locators through nested shadow roots A locator chain crosses three nested open shadow roots to reach an input, while a single CSS selector stops at the first boundary. my-dialog (shadow root) my-form (shadow root) my-input (shadow root) input field target node chained locators pierce each root
Each chained locator step crosses one open shadow boundary; a single flat CSS selector would stop at the outermost root.

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.

The distinction matters because the encapsulation is not accidental. A framework author who ships <my-dialog> wants to change its internal markup without breaking every test and page that consumes it, and the shadow boundary is the contract that makes that safe. A shadow-piercing combinator would have let arbitrary outside selectors reach in and bind to private structure, re-coupling consumers to internals the author never meant to expose. Playwright's per-step descent keeps that contract intact: you name each layer you intend to cross, so the traversal reads as a deliberate path through the component tree rather than a blunt wildcard that ignores boundaries entirely.

How a chained locator resolves across boundaries

When you call an action on the final locator, Playwright walks the chain lazily at that moment rather than caching a stale node. It resolves the outermost host in the light DOM, steps into its open shadow root, resolves the next host, and repeats until it reaches the target — re-running the whole walk on each retry so a late-upgrading web component is handled by the built-in waiting rather than by a manual sleep.

Locator chain resolution sequence A sequence diagram showing the test asking the locator engine to resolve a chain, which pierces each nested shadow root in turn before returning an actionable node. Test spec Locator engine Component tree resolve chain pierce my-dialog root pierce my-form root pierce my-input root return matched node actionable locator
The engine re-walks this sequence on every retry, so a component that upgrades late is resolved by built-in waiting rather than a fixed delay.

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
});

Read the chain top to bottom and it mirrors the component tree exactly: my-dialog is a normal element in the document, my-form lives inside the dialog's shadow root, and the <input> the getByRole() call resolves sits two more roots down. Nothing in the code announces "cross a boundary here" — each step is an ordinary locator call, and the piercing is implicit. That is the mental model to carry into every deeper case: you describe the path in terms of the components a user would name, and the engine handles the shadow plumbing. If you paste page.locator('my-dialog input') into the same test it resolves to zero elements and times out, which is the clearest signal that a boundary sits between the host and the node you want.

Step-by-step fix

  1. Chain one locator per logical layer. Start from the outermost custom element in the light DOM, then call .locator() or a getBy* method for each nested component. Each step crosses one open shadow boundary, so the chain mirrors the component tree.
  2. Prefer role and text queries inside the chain. Use getByRole() and getByText() 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.
  3. 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.
  4. 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.
  5. 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 with page.evaluate() against the host's exposed reference.
  6. Scope to disambiguate repeats. When several my-input instances exist, anchor the chain on a labeled ancestor (getByRole('group', { name: 'Billing' })) before the final step so the locator resolves to exactly one element.

Choosing a traversal approach

Four techniques have circulated for reaching shadow content, and only one is both supported and encapsulation-safe. The removed combinators simply do not match; a flat descendant selector stops at the first boundary; chained locators pierce every open root; and a page.evaluate() hook is the escape hatch reserved for the closed-root case, where no locator can help. Reach for the last option only when you own the component or a vendor exposes a reference, because it steps outside the resilient locator model and re-couples the test to internal structure.

Shadow traversal approaches compared A matrix rating four traversal approaches on whether they cross open roots, reach closed roots, and remain supported today. Open roots Closed roots Supported now >>> / deep combinator no no no Flat CSS descendant no no yes Chained locators yes no yes page.evaluate() hook yes yes yes
Chained locators (highlighted) are the default; the evaluate hook is reserved for closed roots where no locator can reach.

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. When you cannot rebuild the component but its author exposes a reference to the inner node — a common pattern is a ref property or a public getter on the host — you can bridge to it with an evaluate hook, accepting that this couples the test to internal structure and forgoes auto-waiting:

import { test, expect } from '@playwright/test';

test('reads a value out of a closed-root component', async ({ page }) => {
  await page.goto('/settings');

  // The host exposes `.inputRef` — a reference the component author published.
  // page.evaluate runs in the browser, where closed roots are still reachable
  // from a held reference even though locators cannot see them.
  const value = await page.locator('my-secure-input').evaluate(
    (host: any) => host.inputRef?.value ?? null // read straight off the exposed node
  );

  expect(value).toBe('');
});

Treat this as the exception, not the pattern: it works only because the author chose to expose inputRef, and it breaks the moment that private field is renamed.

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. A useful check: if the node appears as a direct child of the custom element in the elements panel, it is slotted light DOM and you address it from outside; if it appears under a shadow-root marker, it is genuine shadow content and the chain reaches it. Mixing the two — expecting a slotted label to resolve from inside the component — is a frequent cause of a locator that "should" match but does not.

The chain is correct but resolves to several elements

Auto-piercing found more than one match, which surfaces as a strict-mode violation rather than a silent wrong pick. Anchor an earlier step on something unique — a labeled getByRole('group') ancestor, or a filter() on visible text — so the final segment has exactly one candidate. This is the same disambiguation discipline that keeps flat-DOM locators stable; the shadow boundaries do not change the fix, they only add layers where an ambiguous match can hide.

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, and it is often the fastest way to discover the exact component names when you did not author the tree. 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. When a chain is flaky rather than flatly broken, the trace almost always shows the outer host resolving before an inner component has upgraded — the fix is to wait on the host's visibility first, not to pad the test with a fixed delay.

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.

Do I have to name every intermediate component in the chain?

Only when you need the extra precision. Because auto-piercing already descends through open roots, a single page.getByRole('textbox', { name: 'Email' }) can match a control several boundaries deep on its own. Add explicit .locator() steps when a page holds repeated components and you must pin the target to one branch, or when scoping to a labeled ancestor makes the intent clearer to the next reader.

Back to overview