Two of the most-asked Playwright Stack Overflow questions of 2026 are about shadow DOM, and another batch is about iframes. The third batch — the brutal one — is about what happens when you combine them. Stripe Elements is an iframe. Inside that iframe is a shadow DOM. To click the card-number field, you have to traverse both worlds.
This combination is where Playwright's defaults stop being enough. Auto-waiting works. Locator chaining works. But the moment you have a closed shadow root inside an iframe, you're in undocumented territory. GitHub Issue #23047 has been open since 2023 asking for a flag to force-pierce closed shadow roots, and as of Playwright 1.59 it's still not addressed.
Here's the playbook I use on three real projects: Stripe Elements, Intercom messenger, and a custom Lit-element design system that ships components with closed shadow roots.
Table of Contents
- What Playwright actually does with shadow DOM
- Open shadow DOM: the easy case
- Iframes: frameLocator and the wait gotcha
- Iframe + open shadow DOM: the standard pattern
- Closed shadow DOM: the workaround
- Real example: Stripe Elements
- Real example: Intercom messenger
- Real example: Lit-element with closed shadow root
- FAQs
What Playwright Actually Does With Shadow DOM
By default, Playwright's locator engine pierces open shadow roots automatically. That means:
// Custom element with open shadow root containing <input>
await page.getByRole('textbox').fill('hello');
// This works without any explicit shadow-piercing code
The locator engine walks the regular DOM and all open shadow roots, looking for a match. You don't write ::shadow selectors anymore (those don't work in modern Chromium anyway).
Two things this does NOT do:
- It doesn't pierce closed shadow roots. Closed roots are inaccessible from outside JavaScript by design — that's the entire point of marking them closed.
- It doesn't reach across iframe boundaries. Iframes are separate frame contexts; you have to scope your locator to the frame first.
Open Shadow DOM: The Easy Case
// HTML structure:
// <custom-input>
// #shadow-root (open)
// <input type="text" placeholder="Email" />
// </custom-input>
await page.getByPlaceholder('Email').fill('test@example.com');
await expect(page.getByPlaceholder('Email')).toHaveValue('test@example.com');
That's it. Playwright finds the input across the shadow boundary. Don't reach for locator('css=*>>>input') — it's unnecessary and sometimes breaks.
Iframes: frameLocator and the Wait Gotcha
const frame = page.frameLocator('iframe[name="checkout"]');
await frame.getByRole('textbox', { name: 'Card number' }).fill('4242424242424242');
frameLocator is lazy — it doesn't fail if the iframe isn't loaded yet. It waits until the iframe is in the DOM and then resolves. The gotcha: it waits for the iframe element, not the iframe's content. If the iframe element is in the DOM but its src hasn't loaded, your locator will time out looking for an element inside an empty iframe.
The fix: wait for the iframe to fire its load event:
await page.waitForLoadState('networkidle');
const frame = page.frameLocator('iframe[name="checkout"]');
// or:
await page.locator('iframe[name="checkout"]').waitFor();
const frameElement = await page.locator('iframe[name="checkout"]').elementHandle();
const contentFrame = await frameElement!.contentFrame();
For most cases frameLocator + a generous web-first assertion is enough. For third-party iframes (Stripe, Intercom), wait for a known element inside the iframe before you start interacting:
const frame = page.frameLocator('iframe[name="__privateStripeFrame"]');
await expect(frame.locator('input[name="cardnumber"]')).toBeVisible();
await frame.locator('input[name="cardnumber"]').fill('4242424242424242');
Iframe + Open Shadow DOM: The Standard Pattern
This is what trips most teams up. The pattern:
// Get into the iframe first
const frame = page.frameLocator('iframe[name="checkout"]');
// Then chain locators normally - shadow DOM piercing happens automatically
await frame.getByRole('textbox', { name: 'Email' }).fill('user@test.com');
// ↑ This pierces the iframe AND any open shadow roots inside it
The thing to remember: once you have a FrameLocator, all child locators behave like they do on a regular page. They auto-wait, they pierce open shadow roots, they work with role-based selectors. The iframe is a one-time scoping operation, not something you have to repeat for every interaction.
Two-level iframe nesting (iframe inside an iframe — yes, it happens):
const outerFrame = page.frameLocator('iframe[name="checkout"]');
const innerFrame = outerFrame.frameLocator('iframe[name="card-element"]');
await innerFrame.getByRole('textbox', { name: 'Card number' }).fill('4242424242424242');
Closed Shadow DOM: The Workaround
If a component author marked the shadow root closed (this.attachShadow({ mode: 'closed' })), Playwright cannot pierce it. The component holds the only JavaScript reference to its shadow root, and exposes nothing to the outside.
You have three options:
Option 1: Ask the team to switch to open mode
This is the right answer if you control the codebase. Closed shadow roots have almost no real security benefit — anyone with DevTools can still inspect them. The downside is exclusively that automation can't reach inside. If your design system uses closed shadow roots and you're trying to test it, file the ticket.
Option 2: Use page.evaluate() to break in
If you have a reference to the host element, you can sometimes execute privileged code inside it. This depends on the component author's implementation:
// If the component exposes a method that operates on its shadow content:
await page.evaluate(() => {
const widget = document.querySelector('my-widget') as any;
// Component-specific API:
widget.setValue('hello');
});
Some libraries (Lit, Stencil) expose properties or methods on the host element that mutate the shadow content. Read the component's public API.
Option 3: Test through user-facing events
If the component fires standard DOM events when its internal state changes, you can dispatch synthetic events from outside:
await page.evaluate(() => {
const widget = document.querySelector('my-widget')!;
widget.dispatchEvent(new CustomEvent('value-change', {
detail: { value: 'hello' },
bubbles: true,
composed: true,
}));
});
The composed: true flag is critical — without it, the event doesn't cross shadow boundaries.
What you cannot do
You cannot use force: true, { trial: true }, or any Playwright option to force-click into a closed shadow root. The browser's protocol simply doesn't expose the root. Issue #23047 is asking for an opt-in flag at the browser-launch level (similar to Chromium's --disable-site-isolation-trials) — until that ships, options 1–3 are it.
Real Example: Stripe Elements
test('checkout - successful card payment', async ({ page }) => {
await page.goto('/checkout');
// Fill cart-level fields (regular DOM)
await page.getByRole('textbox', { name: 'Email' }).fill('buyer@test.com');
// Stripe loads three nested iframes - one per field
const cardFrame = page.frameLocator('iframe[title*="Secure card number"]');
await expect(cardFrame.locator('input[name="cardnumber"]')).toBeVisible();
await cardFrame.locator('input[name="cardnumber"]').fill('4242424242424242');
const expFrame = page.frameLocator('iframe[title*="Secure expiration"]');
await expFrame.locator('input[name="exp-date"]').fill('12/30');
const cvcFrame = page.frameLocator('iframe[title*="Secure CVC"]');
await cvcFrame.locator('input[name="cvc"]').fill('123');
await Promise.all([
page.waitForResponse(r => r.url().includes('payment_intents')),
page.getByRole('button', { name: 'Pay' }).click(),
]);
await expect(page).toHaveURL(/\/thank-you/);
});
Note the iframe[title*=...] selectors. Stripe iframes have stable title attributes but not stable name attributes — names rotate per session. Title is your friend.
Use Stripe's test card numbers; never real cards. The 4242424242424242 always succeeds. 4000000000000002 always declines. Stripe's docs have the full list.
Real Example: Intercom Messenger
Intercom embeds a launcher button in the regular DOM, but the actual messenger window opens inside an iframe.
test('user can send a message to support', async ({ page }) => {
await page.goto('/');
// Click launcher (in regular DOM)
await page.locator('.intercom-launcher').click();
// Wait for the messenger iframe and scope to it
const messenger = page.frameLocator('iframe[name="intercom-messenger-frame"]');
await expect(messenger.getByRole('textbox', { name: 'Type a message...' })).toBeVisible();
await messenger.getByRole('textbox', { name: 'Type a message...' }).fill('Hello support');
await messenger.getByRole('button', { name: 'Send' }).click();
await expect(messenger.getByText('Hello support')).toBeVisible();
});
One thing to know: Intercom rate-limits messages from anonymous users. If your tests fire 100 messages in a CI run, the 50th will silently fail. Use a test workspace with rate-limiting disabled, or mock the Intercom widget entirely (load intercom-snippet only on production, not test envs).
Real Example: Lit-Element With Closed Shadow Root
One client's design system shipped buttons as Lit elements with closed shadow roots. The button label was inside the closed root. page.getByRole('button') couldn't see the text.
Fix: I asked the design system team to switch to open mode for accessibility reasons (closed shadow roots also break some screen readers). They agreed and shipped within a sprint.
Workaround for the in-between weeks:
test('add to cart from product card', async ({ page }) => {
await page.goto('/products/widget');
// Closed shadow root - can't see the button label from outside.
// Use the host element's data attributes instead:
const addBtn = page.locator('ds-button[data-action="add-to-cart"]');
await addBtn.click(); // Click events propagate even with closed shadow
await expect(page.getByRole('alert')).toContainText('Added to cart');
});
Click events go through closed shadow roots because they bubble out via composed: true by default. Reading text inside the closed root is what's blocked.
FAQs
Why is GitHub Issue #23047 still open?
The Playwright maintainers correctly point out that piercing closed shadow roots requires a Chrome DevTools Protocol change. It's not just a Playwright decision. The right fix is for the spec to add an opt-in inspection flag, and that's not on any browser vendor's near-term roadmap.
Does locator('::shadow ...') still work?
No. The ::shadow and ::deep CSS combinators were deprecated years ago and removed from Chromium. Use Playwright's locator chaining and let the engine handle shadow piercing.
How do I test inside an open shadow root that contains another open shadow root?
Just chain locators. Playwright walks all nested open shadow roots automatically. page.getByText('hello') finds 'hello' across any depth of open shadow nesting.
Can I use page.evaluate to read text from a closed shadow root?
Generally no — the shadow root reference is private to the component instance. The only way to read it is if the component exposes a public method or property that does so. Some component libraries do this for testing purposes; check the library's testing docs.
What about Web Components from npm packages?
Most major libraries (Lit, Stencil, Vaadin) ship with open shadow roots by default. Check before assuming. document.querySelector('my-element').shadowRoot in DevTools — if it's null, the root is closed.
Does frameLocator work for cross-origin iframes?
Yes, but you can't access contentFrame() for them. frameLocator uses the browser's protocol to interact across origin boundaries, which works for clicks, fills, and assertions.
How do I know which iframe my element is in?
DevTools → Elements → right-click on the iframe → "Show frame in console". Then $0.title gives you the title attribute. Use that as your frameLocator selector.
Can I attach Playwright DevTools inside an iframe?
The trace viewer shows iframe interactions inline with parent-frame interactions. You don't need to attach separately.
Does this all change with Playwright 1.59 agents?
The accessibility-snapshot improvements help agents understand iframe + shadow DOM structures better. Manual test code uses the same APIs as before.
What about Playwright Component Testing? Same rules?
Component testing renders your component in isolation, so shadow DOM works the same. Iframes are unusual in component tests (you'd typically test the iframe content as its own component). See my Component Testing post.
Wrap-Up
Iframe + shadow DOM is hard for one specific reason: the abstractions are designed to isolate things from each other, and tests are designed to inspect them. Playwright handles 95% of this transparently. The remaining 5% — closed shadow roots — is a browser-protocol limitation, not a Playwright bug.
If you're testing payment forms, embedded chat widgets, or a custom design system and you're stuck on this combination, I do framework engagements that include exactly this kind of advanced locator work. Or book a free call and I'll look at one of your stuck tests on the call.
Related reading:
Tayyab Akmal
AI & QA Automation Engineer
6 years of catching critical bugs in fintech, e-commerce, and SaaS — then building the Playwright and Selenium automation that prevents them from shipping again.