How to fix Playwright selectors that fail with Shadow DOM components?

Playwright

Shadow DOM components encapsulate their internal DOM in a shadow root that is intentionally hidden from the main document's CSS selector scope. Standard CSS selectors cannot cross this boundary, so page.locator('my-button > button') will silently find nothing if <button> lives inside a shadow root. This is the most common reason selectors that work in DevTools don't work in Playwright. Understanding how Playwright shadow DOM selectors pierce shadow boundaries — and when they do so automatically — is key to writing stable tests for web components.

Common mistake

test('submits order', async ({ page }) => {
  await page.goto('https://app.example.com/checkout');

  // Fails silently — button is inside a Shadow DOM web component
  await page.locator('checkout-form button[type="submit"]').click();
  //                                   ^^^^^^^^^^^^^^^^ hidden behind shadow root
});

Also broken: using CSS descendant selectors that assume all elements are in the light DOM.

The fix

Playwright's user-facing locators pierce Shadow DOM by default — this is the simplest approach:

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

test('submits checkout form', async ({ page }) => {
  await page.goto('https://app.example.com/checkout');

  // getByRole, getByLabel, getByText all pierce Shadow DOM automatically
  await page.getByLabel('Card number').fill('4242 4242 4242 4242');
  await page.getByRole('button', { name: 'Place order' }).click();

  await expect(page.getByText('Order confirmed')).toBeVisible();
});

When you must use a CSS selector that crosses a shadow boundary, use Playwright's >> pierce combinator:

// Pierce: find 'button.submit' anywhere inside the shadow root of 'checkout-form'
await page.locator('checkout-form >> css=button.submit').click();

For deeply nested shadow roots, chain pierce selectors:

// Traverse: custom-app > shadow-root > order-panel > shadow-root > button
await page.locator('custom-app >> order-panel >> css=button[data-action="submit"]').click();

Using locator.pierce() explicitly when you have a handle to the host element:

const host = page.locator('checkout-form');
await host.locator('css=button[type="submit"]').click();
// Playwright 1.28+ automatically pierces shadow roots for most locator types

Why it works

Playwright selectors pierce shadow DOM boundaries by default for the accessibility tree-based locators: getByRole, getByLabel, getByText, getByPlaceholder, getByAltText, and getByTitle. This works because the Playwright accessibility tree traversal naturally crosses shadow roots the same way screen readers do. For CSS selectors, Playwright uses a custom selector engine that supports a shadow-piercing >> combinator — conceptually similar to the deprecated CSS >>> combinator but implemented in Playwright's own engine. This means playwright shadow dom selectors pierce behavior is opt-in for CSS but automatic for semantic locators.

Tips

  • Prefer getByRole, getByLabel, and getByText over custom CSS when working with web components — they pierce Shadow DOM without any special syntax and are more resilient to component internals changing.
  • Use browser DevTools' accessibility tree view (not the DOM inspector) to find the accessible name of elements inside shadow roots — this is what Playwright's role-based locators query.
  • If a getByRole locator still can't find an element inside a deep shadow root, the component may not be correctly exposing accessibility semantics — inspect with axe-core to check for missing role or aria-label attributes.
  • For Lit, Stencil, or other web component frameworks, check if the framework provides test utilities that expose stable test IDs at the component boundary, reducing the need to pierce multiple shadow roots.