Playwright & Web Automation Hub

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.

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

  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.

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.

Back to overview