Modern design systems — Lit, Stencil, FAST, Salesforce Lightning, Shoelace, Adobe Spectrum — wrap their components in Shadow DOM. The custom element on the outside (e.g., <sl-button>) hides the actual <button>, the styles, the internal markup, behind a shadow boundary. Other tools struggle with this: Selenium needs explicit shadow-piercing, Cypress requires includeShadowDom: true config or .shadow() chains. Playwright pierces open Shadow DOM by default — getByRole, getByText, and most CSS selectors descend through shadow boundaries automatically. This lesson is what Shadow DOM actually is, where Playwright's piercing works (and the rare cases where it doesn't), and the patterns for testing rich design-system components in real apps.
What Shadow DOM is, briefly
A Shadow DOM is an encapsulated DOM tree attached to a host element, with its own internal markup, styles, and event flow. Two consequences:
- Styles inside don't leak out, and styles outside don't leak in. A
<sl-button>looks the same on every page regardless of the parent's CSS. - The shadow tree is invisible to most DOM queries.
document.querySelector('button')doesn't find buttons inside shadow roots — only the host element (<sl-button>) shows up.
Shadow DOM is created in two flavours:
- Open — the shadow root is accessible via
element.shadowRoot. Most design systems use this. - Closed — the shadow root is hidden from JavaScript. Rare in practice; intentionally inaccessible to test tools.
Playwright works seamlessly with open Shadow DOM — which is what 99% of real components use.
Playwright pierces by default
The headline: most Playwright locators just work, regardless of shadow boundaries.
// All of these work even if the button is inside a shadow root
await page.getByRole("button", { name: "Submit" }).click();
await page.getByText("Add to cart").click();
await page.getByLabel("Email address").fill("alice@test.com");
await page.getByPlaceholder("Search").fill("laptop");
await page.getByTestId("submit-btn").click();The accessibility-based locators (getByRole, getByLabel, getByText, getByPlaceholder) traverse the accessibility tree — which spans shadow boundaries by design. The result: the same locator API you've used for nine lessons works on web components without any special syntax.
CSS selectors mostly pierce too:
// CSS selectors pierce shadow DOM in modern Playwright
await page.locator(".product-card").click();
await page.locator("input[name='email']").fill("alice@test.com");The exception: certain pseudo-classes that depend on the shallow DOM structure (e.g., :nth-child in a way that mixes shadow and light DOM) can produce surprising results. When in doubt, prefer getByRole and friends.
Chaining locators inside a custom element
For complex components — a <custom-data-table> with rows and cells — chain locators off the host element:
const table = page.locator("custom-data-table");
// All locator methods work on the chained handle, with shadow piercing
await expect(table.getByRole("row")).toHaveCount(10);
await table.getByRole("row").filter({ hasText: "Alice" }).getByRole("button", { name: "Edit" }).click();The chain reads exactly like any other Playwright locator chain. The fact that <custom-data-table> has shadow DOM is invisible.
A complete custom-component test
Imagine your app uses a <custom-date-picker>:
import { test, expect } from "@playwright/test";
test("custom date picker selects December 25", async ({ page }) => {
await page.goto("/booking");
const datePicker = page.locator("custom-date-picker");
// Open the calendar
await datePicker.getByRole("button", { name: "Open calendar" }).click();
// Navigate to the right month if needed
while (!(await datePicker.getByText("December 2026").isVisible())) {
await datePicker.getByRole("button", { name: "Next month" }).click();
}
// Click the day
await datePicker.getByRole("gridcell", { name: "25" }).click();
// Assert the selected date renders
await expect(datePicker.getByRole("textbox")).toHaveValue("2026-12-25");
});The test never thinks about Shadow DOM. It treats <custom-date-picker> as any other locator scope — descend, interact, assert.
The shadow-DOM mental map
- – getByRole / getByLabel / getByText pierce automatically
- – CSS selectors pierce in modern Playwright
- – Chain locators off the host element for scoping
- – Intentionally hidden from any test tool
- – Accessibility-based locators may still work via the a11y tree
- – If the team controls the component, switch to open mode
- – Lit, Stencil, FAST
- – Shoelace, Adobe Spectrum, Salesforce Lightning
- – All use open Shadow DOM — work natively in Playwright
- Closed shadow root — can't reach in by design –
- :nth-child mixing shadow + light DOM — use filter() instead –
- Custom event listeners — dispatch via page.dispatchEvent() –
A practical pattern — wrapping a component in a page object
When the same custom element appears across many pages, wrap it in a component object (chapter 6, lesson 1) so the test reads at the right level of abstraction:
// pages/components/CustomDatePicker.ts
import { Locator, Page, expect } from "@playwright/test";
export class CustomDatePicker {
private readonly host: Locator;
private readonly openButton: Locator;
private readonly nextMonthButton: Locator;
private readonly textbox: Locator;
constructor(scope: Page | Locator) {
this.host = "locator" in scope ? scope.locator("custom-date-picker") : scope.locator("custom-date-picker");
this.openButton = this.host.getByRole("button", { name: "Open calendar" });
this.nextMonthButton = this.host.getByRole("button", { name: "Next month" });
this.textbox = this.host.getByRole("textbox");
}
async select(year: number, monthName: string, day: number) {
await this.openButton.click();
while (!(await this.host.getByText(`${monthName} ${year}`).isVisible())) {
await this.nextMonthButton.click();
}
await this.host.getByRole("gridcell", { name: String(day) }).click();
}
async expectValue(iso: string) {
await expect(this.textbox).toHaveValue(iso);
}
}A test that uses the wrapper:
import { test, expect } from "@playwright/test";
import { CustomDatePicker } from "../pages/components/CustomDatePicker";
test("books a December 25 slot", async ({ page }) => {
await page.goto("/booking");
const datePicker = new CustomDatePicker(page);
await datePicker.select(2026, "December", 25);
await datePicker.expectValue("2026-12-25");
});The component object hides every shadow-DOM detail; the test reads as plain English.
The closed-shadow-root case (rare)
If a component uses closed Shadow DOM (element.attachShadow({ mode: 'closed' })), the shadow root is intentionally inaccessible to JavaScript — including any test tool. You can still:
- Click the host element.
await page.locator('closed-element').click()fires the click on the host; whatever the component does internally happens. - Use accessibility-based locators.
getByRole,getByLabel,getByTextrely on the accessibility tree, which is built by the platform regardless of shadow mode. They often work even on closed shadow. - Use
evaluatewith knowledge of the component's API. If the component exposes methods (element.openCalendar()), call them viapage.evaluate.
In real apps, closed Shadow DOM is exotic. If you're testing a third-party widget that uses it (e.g., some payment iframes wrap their iframe content in closed shadow), accept that you can only assert on the host's outer behaviour, not its internals.
Coming from Cypress?
The mappings:
- Cypress: enable
includeShadowDom: trueglobally, or usecy.get(...).shadow().find(...)per query. - Playwright: nothing. Locators pierce by default.
If your Cypress tests are sprinkled with .shadow() chains, the migration to Playwright drops them entirely. cy.get('app-card').shadow().find('button').click() becomes await page.locator('app-card').getByRole('button').click() — same intent, less plumbing, plus the resilience of role-based locators.
⚠️ Common mistakes
- Assuming all locators pierce — they don't always.
getByRole,getByLabel,getByText,getByTestIdreliably pierce. Some CSS pseudo-classes (:nth-child,:hasin tricky cases) can behave unexpectedly across boundaries. When a CSS selector fails on a shadow component, switch to a semantic locator. - Treating shadow DOM as a debugging excuse. "It's flaky because of Shadow DOM" is rarely the actual reason — modern Playwright handles open Shadow DOM cleanly. The flake is more often a missing
await, a race against animation, or a stale element handle. Open the trace and look at what actually happened. - Trying to query closed Shadow DOM with
evaluate(() => element.shadowRoot.querySelector(...)). It returnsnull— that's the entire point of "closed." If you need to test the internals of a closed-shadow component, you need access to the component's source to switch it to open mode, or to add the test hooks (data-testidon the host) that pierce-friendly tools can target.
🎯 Practice task
Build tests against a real web-components page. 25-30 minutes.
-
Use the TodoMVC web-components reference — Playwright's official demo app. It uses real custom elements and is publicly hosted.
-
Create
tests/web-components.spec.ts:import { test, expect } from "@playwright/test"; test.describe("Web components — TodoMVC", () => { test.beforeEach(async ({ page }) => { await page.goto("https://demo.playwright.dev/todomvc"); }); test("getByPlaceholder pierces — adds a todo", async ({ page }) => { await page.getByPlaceholder("What needs to be done?").fill("Learn Playwright"); await page.getByPlaceholder("What needs to be done?").press("Enter"); await expect(page.getByTestId("todo-title")).toHaveText("Learn Playwright"); }); test("getByRole pierces — completes a todo", async ({ page }) => { await page.getByPlaceholder("What needs to be done?").fill("First task"); await page.getByPlaceholder("What needs to be done?").press("Enter"); await page.getByRole("checkbox", { name: "Toggle Todo" }).first().check(); await expect(page.getByTestId("todo-item")).toHaveClass(/completed/); }); test("filter() chained inside a custom element", async ({ page }) => { const items = ["Buy milk", "Write tests", "Ship feature"]; for (const text of items) { await page.getByPlaceholder("What needs to be done?").fill(text); await page.getByPlaceholder("What needs to be done?").press("Enter"); } await expect(page.getByTestId("todo-item")).toHaveCount(3); // Find and complete just "Write tests" await page .getByTestId("todo-item") .filter({ hasText: "Write tests" }) .getByRole("checkbox") .check(); await expect( page.getByTestId("todo-item").filter({ hasText: "Write tests" }) ).toHaveClass(/completed/); }); test("delete a todo via hover-revealed button", async ({ page }) => { await page.getByPlaceholder("What needs to be done?").fill("Deletable"); await page.getByPlaceholder("What needs to be done?").press("Enter"); const todo = page.getByTestId("todo-item").filter({ hasText: "Deletable" }); await todo.hover(); await todo.getByRole("button", { name: "Delete" }).click(); await expect(page.getByTestId("todo-item")).toHaveCount(0); }); }); -
Run all four tests across all three browsers. None of them touch Shadow DOM explicitly — yet they all work because Playwright pierces automatically.
-
Wrap a custom element in a component object. Create
pages/components/TodoListWidget.tsand move the todo-related logic in. The test reads asawait todoList.add('Buy milk'); await todoList.complete('Buy milk')— pure intent, no shadow-DOM gymnastics. -
Stretch: find a real design-system page on your own app (or any public app — Shoelace's showcase is a good one). Pick a component with non-trivial markup (date picker, dropdown, accordion). Write a test that interacts with it using only
getByRole,getByLabel, orgetByText. If the test struggles, inspect the component to confirm its shadow root isopenmode (if it'sclosed, you've found the rare exception and can fall back to host-level interactions).
That closes Chapter 6 — advanced patterns. You now have:
- Page Object Model for selector consolidation at scale
- Multi-browser projects for cross-engine coverage with zero code changes
- Mobile emulation via the device descriptor system
- Storage state authentication that turns 200 logged-in tests into a 2-second start
- Shadow DOM piercing that handles modern design systems by default
The next chapter shifts from interaction patterns to quality patterns — visual regression testing with screenshot diffs and accessibility testing with axe-core. The locators and component objects you've built in this chapter are the foundation; chapter 7 adds the assertions that catch design and a11y regressions before users see them.