Playwright & Web Automation Hub

Playwright architecture, selector reliability, and advanced interaction patterns.

Shadow DOM Traversal

Web components hide their internal markup behind shadow roots so that styles and state never collide with the surrounding page. That encapsulation is good for component authors and historically painful for automation engineers, who had to chain shadowRoot calls by hand to reach a button buried two layers deep. Playwright removes most of that pain: its locator engine pierces open shadow boundaries automatically, treating encapsulated nodes as if they belonged to the main document. This guide explains how that auto-piercing works, when it stops working, and how to traverse nested and closed roots reliably as part of a wider approach to Reliable Selector Strategies for Playwright.

Nested shadow host tree and how locators pierce it Light DOM contains a custom element whose open shadow root holds a nested host; a Playwright locator pierces open roots automatically while a closed root needs a page.evaluate fallback. Light DOM (document) <data-grid> host open shadowRoot <grid-row> nested host open shadowRoot .action-btn locator() auto-pierces open roots page.evaluate() closed-root fallback
A single locator descends through open shadow roots automatically; a closed root blocks the engine, so you drop into page.evaluate to read it.

The anatomy of a shadow tree

Before traversing one, it helps to name the parts. A shadow host is an ordinary element in the regular document — the light DOM — that has had a private subtree attached to it. That private subtree is the shadow root, and everything rendered beneath it is the shadow tree. The host is visible to the outside page; the tree beneath it is not. From the document's perspective the host looks like an empty <custom-widget></custom-widget>, even though it renders a rich interface internally.

The boundary between the light DOM and the shadow tree is what makes encapsulation real. CSS rules written at the page level do not cascade across it, JavaScript querySelector from the document does not descend through it, and event retargeting rewrites events so the outside page sees them as originating from the host rather than from some internal node. This is exactly why a button styled by a design system keeps its look no matter what the consuming page's stylesheet does — and exactly why naive automation that assumes one flat document fails.

Two more concepts matter for traversal. Slots are placeholders inside the shadow tree (<slot>) where the host's own light-DOM children are projected. Content the page author writes between the host's tags — its slotted content — physically stays in the light DOM but is rendered visually at the slot's position. This distinction trips up locators: slotted text is found by querying the light DOM, not the shadow tree, even though it appears nested inside the component on screen. The assigned nodes of a slot are exactly those projected light-DOM elements, and they remain reachable with ordinary page.locator() because they never left the document.

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

test('slotted content lives in the light DOM, not the shadow tree', async ({ page }) => {
  await page.goto('/widgets');

  // The <app-card> shadow tree renders a <slot>; the heading the page author
  // wrote between the host's tags is projected INTO that slot but physically
  // stays in the light DOM. So we query it as an ordinary light-DOM element.
  const slottedHeading = page.locator('app-card h2.card-title');
  await expect(slottedHeading).toHaveText('Quarterly report');

  // By contrast, .card-frame is rendered INSIDE the shadow root. Chaining from
  // the host pierces the boundary to reach it; a flat document query would miss.
  const frame = page.locator('app-card').locator('.card-frame');
  await expect(frame).toBeVisible();
});

How Shadow DOM encapsulation changes targeting

A custom element attaches a shadow root with attachShadow({ mode }). Everything rendered inside that root is isolated: page-level CSS does not style it, and document.querySelector() from the light DOM cannot reach into it. The mode you choose at attach time determines whether automation can see inside.

Because most design systems ship open roots, the common case needs no special handling at all. The hard cases — closed roots, deeply nested hosts, and ambiguous sibling hosts — are where the techniques below matter. For day-to-day component work, prefer semantic queries from getByRole & Accessibility Selectors, and fall back to the structural patterns in CSS & XPath Best Practices only when no accessible name exists.

Detecting boundaries before you run a suite

Before you write locators against an unfamiliar component library, audit which elements host shadow roots and which mode each uses. Querying .shadowRoot from inside page.evaluate() tells you immediately whether a target is reachable by the locator engine or needs a fallback. Run this once and keep the output beside the spec.

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

test('audit shadow roots and their modes', async ({ page }) => {
  await page.goto('/component-gallery');

  // Run in the page context so we can read the live .shadowRoot property.
  const shadowModes = await page.evaluate(() => {
    // Walk every element; only hosts expose a non-null shadowRoot.
    const elements = Array.from(document.querySelectorAll('*'));
    return elements
      .filter((el) => (el as Element & { shadowRoot: ShadowRoot | null }).shadowRoot)
      .map((el) => ({
        tag: el.tagName.toLowerCase(),
        // mode is 'open' for reachable roots; closed roots never appear here.
        mode: (el as Element & { shadowRoot: ShadowRoot }).shadowRoot.mode,
      }));
  });

  // A host that renders a closed root will be ABSENT from this list — that
  // absence is itself the signal that you need the evaluate fallback below.
  console.log('Detected open shadow roots:', shadowModes);
});

Auto-piercing with plain locator chaining

For open roots, the rule is simple: write locators exactly as you would against the light DOM. Start from the host, then chain into the element you want. The engine crosses the boundary for you, retries until the target is actionable, and applies the same visibility and stability checks it uses everywhere else.

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

test('click a button inside an open shadow root', async ({ page }) => {
  await page.goto('/widgets');

  // `custom-widget` lives in the light DOM; `.action-btn` lives in its open
  // shadow root. The chain pierces the boundary automatically.
  const shadowBtn = page.locator('custom-widget').locator('.action-btn');

  // Auto-waiting still applies across the boundary: this blocks until the
  // button is rendered and visible inside the shadow tree.
  await shadowBtn.waitFor({ state: 'visible', timeout: 10000 });
  await shadowBtn.click();

  await expect(page.getByRole('status')).toHaveText('Action complete');
});

A historical note that still trips people up: the >> deep combinator (for example custom-widget >> .action-btn) was a Playwright-specific extension that has been removed. Do not reintroduce it. Standard locator chaining, shown above, is the supported and stable replacement, and it composes cleanly with filters and role queries.

Synchronizing with shadow trees that hydrate late

Components frequently render their shadow contents after an asynchronous fetch or a framework hydration pass. Hardcoded sleeps fail here for the same reason they fail in the light DOM — they ignore actual render completion. Replace them with explicit state waits, the same discipline covered in Handling Dynamic Content. Chain into the shadow element and wait for the state you actually need rather than guessing a duration.

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

test('wait for a lazily rendered shadow element', async ({ page }) => {
  await page.goto('/dashboard');

  const panel = page.locator('analytics-panel');
  // The chart element only appears after the panel finishes its data fetch.
  const chart = panel.locator('.rendered-chart');

  // Wait for the post-hydration state, not an arbitrary timeout.
  await expect(chart).toBeVisible({ timeout: 8000 });
  await expect(chart).toHaveAttribute('data-state', 'ready');
});

Scoping when hosts share identical internals

Auto-piercing is powerful enough that an unscoped query can match the same .row-value inside every host on the page. When several custom elements share an internal structure, anchor to the specific host first and chain from there so the engine never escapes into a sibling component. This is the shadow-tree version of the scoping discipline you apply with attribute hooks from CSS & XPath Best Practices.

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

test('extract one row value per data-grid host', async ({ page }) => {
  await page.goto('/reports');

  // Each <data-grid> renders its own open shadow root with a .row-value node.
  const hosts = page.locator('data-grid');
  const count = await hosts.count();

  // Scope every read to a specific host index so values never cross-contaminate
  // between sibling grids that share the same internal markup.
  const extractedRows = await Promise.all(
    Array.from({ length: count }, (_, i) =>
      hosts.nth(i).locator('.row-value').innerText()
    )
  );

  expect(extractedRows.length).toBe(count);
  expect(extractedRows.every((v) => v.trim().length > 0)).toBe(true);
});

Keeping each read bound to hosts.nth(i) guarantees the extracted dataset maps back to the correct UI instance — important when you later assert per-row values or feed the data into a comparison. For the full production extraction pattern, including error recovery and parallelization, follow Automating Shadow DOM Elements with Playwright.

Nested hosts: a host inside a shadow root

Real component libraries nest hosts: a <data-grid> open root contains <grid-row> elements, each with its own open root containing the actual controls. Because every boundary in the chain is open, a single locator still descends through all of them — you do not chain shadowRoot manually at any level.

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

test('reach a control two shadow levels deep', async ({ page }) => {
  await page.goto('/reports');

  // data-grid (open) -> grid-row (open) -> .edit-btn. One chain pierces both.
  const editButton = page
    .locator('data-grid')
    .locator('grid-row', { hasText: 'Invoice 4821' })
    .locator('.edit-btn');

  await expect(editButton).toBeVisible();
  await editButton.click();
});

When nesting gets deep or sibling hosts become ambiguous in ways a single chain cannot disambiguate, the precise scoping and per-level waiting techniques are detailed in Piercing Nested Shadow DOM Components. The key principle stays constant: scope to the nearest stable host, then chain.

The closed-root fallback

A closed root returns null from host.shadowRoot, so the locator engine cannot enter it. When you control the component you should prefer reopening it as mode: 'open' for testability; when you do not, the supported path is to drop into page.evaluate() and read the closed root from inside the page context, returning a serialized value to your test for assertion.

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

test('read a value from a closed shadow root', async ({ page }) => {
  await page.goto('/secure-widget');

  // Evaluate runs in the page where the closed shadowRoot reference is reachable
  // even though the locator engine cannot pierce it from the test side.
  const closedValue = await page.evaluate(() => {
    const host = document.querySelector('closed-encapsulation');
    // Bail out cleanly if the host is missing or the root is unreadable.
    if (!host || !host.shadowRoot) return null;
    const target = host.shadowRoot.querySelector('.internal-data');
    return target ? target.textContent : null;
  });

  // Validate the serialized payload back in the Playwright context.
  expect(closedValue).not.toBeNull();
  expect(closedValue).toContain('Confirmed');
});

This bypasses the engine's piercing limitation while keeping execution strictly asynchronous. Treat it as a last resort: it reads state but cannot click or type through the same boundary, and it couples your test to the component's internal class names rather than its accessible interface.

Accessibility-first traversal beats brittle internals

The most resilient shadow traversal is often no traversal at all. Interactive elements inside open roots still contribute to the page's accessibility tree, so getByRole() and getByLabel() reach them by semantic intent without you naming a single internal class. This is the same advantage that makes role queries the default everywhere — see getByRole & Accessibility Selectors — and it keeps shadow-hosted controls stable across component-library upgrades.

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

test('target a shadow-hosted control by role', async ({ page }) => {
  await page.goto('/widgets');

  // No internal class names: the role and accessible name resolve the control
  // even though it lives inside an open shadow root.
  const confirm = page.getByRole('button', { name: 'Confirm order' });
  await expect(confirm).toBeEnabled();
  await confirm.click();

  await expect(page.getByRole('alert')).toHaveText('Order placed');
});

Prefer this approach first. Reserve structural shadow chaining for elements that genuinely lack an accessible name, and reserve the page.evaluate() fallback for closed roots you cannot reopen.

Iframes versus shadow roots

Shadow boundaries and iframe boundaries look similar but are not the same, and conflating them causes confusing failures. An iframe is a separate browsing context with its own document; you cross it with page.frameLocator(), not with shadow piercing. A shadow root is part of the same document; you cross it with ordinary locator chaining. Hybrid widgets that embed an iframe inside a shadow root require both tools in sequence: chain into the host, then switch into the frame. Keep the two mental models distinct and your traversal stays predictable.

The practical consequences differ in ways that bite. Because an iframe is a separate document, its accessibility tree is separate too, so getByRole() from the top page will not reach a control inside the frame until you have entered it with frameLocator(). A shadow root, by contrast, contributes its interactive elements to the host page's flattened accessibility tree, which is why role queries reach shadow-hosted controls with no extra step. Styling crosses neither boundary by default, but the escape hatches differ: a frame is sealed off entirely by the same-origin policy, while a shadow root can deliberately expose styling hooks with ::part(). And error symptoms differ — a missing frameLocator() typically yields a timeout because the element never appears in the top document, whereas a shadow mistake more often yields the wrong element because auto-piercing silently matched a sibling.

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

test('cross an iframe boundary, then a shadow boundary inside it', async ({ page }) => {
  await page.goto('/embedded');

  // First hop: enter the iframe's separate document. Shadow piercing cannot
  // cross this — only frameLocator() switches browsing contexts.
  const frame = page.frameLocator('iframe[title="Payment widget"]');

  // Second hop: inside the frame, a web component holds an OPEN shadow root.
  // Now ordinary locator chaining pierces that boundary as usual.
  const submit = frame.locator('pay-button').getByRole('button', { name: 'Pay now' });

  await expect(submit).toBeEnabled();
  await submit.click();
});

Why CSS descendant selectors stop at the boundary

A frequent source of confusion is that a single CSS string does not cross a shadow boundary even though Playwright locator chaining does. The selector custom-widget .action-btn passed as one argument to page.locator() is interpreted as a single CSS query, and CSS descendant combinators do not pierce shadow roots — so it matches nothing inside the tree. The working form splits the query into chained .locator() calls: page.locator('custom-widget').locator('.action-btn'). Each call is a separate resolution step, and it is the chaining, not the CSS, that crosses the boundary. Keeping this distinction in mind explains most "my selector works in DevTools but not in the test" reports: the DevTools console was reaching across boundaries you wrote by hand, while a flat CSS string in your test was not.

This also clarifies what ::part() is for. The CSS Shadow Parts spec lets a component author tag internal elements with a part attribute, which the outside page can then style with the ::part() pseudo-element — and a parent component can re-expose a child's parts upward with exportparts. For automation, ::part() is occasionally useful as a stable selector hook because it is an intentional, published surface the component author committed to, unlike an internal class name that can change without notice. When a library exposes parts, targeting host::part(label) is more durable than reaching for the underlying class.

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

test('select an intentionally exposed shadow part', async ({ page }) => {
  await page.goto('/widgets');

  // The component author published part="label" on an internal node and, for a
  // nested child, re-exposed it upward with exportparts. Both are stable hooks
  // the author committed to — more durable than internal class names.
  const label = page.locator('rating-widget').locator('::part(label)');

  await expect(label).toHaveText('Excellent');
});

Patterns across common web-component libraries

The traversal rules are universal, but each ecosystem has habits worth knowing so you reach for the right tool first.

Whichever library you face, the decision order is the same: try a role or label query, then scoped locator chaining for unnamed internals, then ::part() if the author exposed one, and only then the evaluate fallback. These same component-heavy interfaces are where interaction work in Advanced Interactions & Test Assertions most often runs into encapsulation, so a reliable traversal habit pays off well beyond selection alone.

Debugging shadow selectors

When a shadow locator misbehaves, generate it rather than guessing. Running npx playwright codegen <url> and clicking the target produces a recommended locator that already accounts for shadow piercing, which is the fastest way to see whether Playwright considers an element role-addressable or only reachable by structural chaining. If codegen offers a getByRole() line, prefer it; if it falls back to a CSS chain, that tells you the element lacks an accessible name and you should weigh adding one to the component.

The Playwright Inspector, launched with PWDEBUG=1 or --debug, lets you hover a locator and watch it highlight across the boundary in real time, so you can confirm an unscoped query is matching one host rather than several. Pair this with the boundary audit from earlier: if the audit shows a host is missing from the open-roots list, no amount of locator tuning will pierce it, and you can move straight to the closed-root strategy instead of burning time on selectors that can never resolve. Treat a persistently empty highlight as a signal to check whether you are facing a closed root or an iframe rather than assuming the selector text is wrong.

Frequently Asked Questions

Does Playwright need special syntax to enter Shadow DOM?

No. For open shadow roots, ordinary locator chaining such as page.locator('host').locator('.child') pierces the boundary automatically and applies normal auto-waiting. The old >> deep combinator was removed, so use standard chaining instead.

How do I read an element inside a closed shadow root?

The locator engine cannot enter a closed root because host.shadowRoot returns null. Drop into page.evaluate(), access the element from inside the page context, and return a serialized value to assert on. If you own the component, reopening it as mode: 'open' is the better long-term fix.

Why do my shadow locators match the wrong component?

Auto-piercing means an unscoped query can match identical internals across every host on the page. Anchor to a specific host first — for example page.locator('data-grid').nth(i).locator('.row-value') — so each read stays bound to the correct instance.

Back to overview