Home / Blog / Playwright iframes and shadow DOM
Playwright iframes and shadow DOM: which of the two is your problem
Playwright's locators already reach into open shadow roots, so if your element sits in a web
component there is nothing to add. If it sits in an iframe there is. An iframe is a separate
document and page-level actions are assumed to operate in the main frame, so nothing sees
inside until you scope to it with page.frameLocator() or
locator.contentFrame() and locate within that.
Two questions arrive in one search and one of them is already answered. Which one you are holding takes a minute to settle in devtools.
Shadow DOM or an iframe — which are you looking at?
Open the element panel and look at what sits above the thing your locator cannot reach.
A shadow root shows as a #shadow-root node under a host element — a custom tag
like <x-summary>, or a component out of a library. Everything under it is
still the same document, with one encapsulation boundary drawn across it.
An iframe shows as an <iframe> element with a #document node
inside it. That document has its own tree, its own URL and its own scripts, and it is the thing
the word "frame" means everywhere in Playwright's API.
If it is an open shadow root you have nothing to do, because Playwright's locators cross that boundary on their own. If it is an iframe, every locator has to be told which frame it describes before it can see anything at all, and the rest of this page is about that.
One case looks like both and is neither: a WebView inside a native app on iOS or Android. Playwright attaches to a page in a browser it launched — Chromium, Firefox or WebKit — and a native app has no such page in it.
Does Playwright work with shadow DOM?
The documentation answers it outright, in the Locate in Shadow DOM section of
docs/locators: "All locators in Playwright by default work with elements in
Shadow DOM. The exceptions are:" — followed by two, and only two. "Locating by XPath
does not pierce shadow roots." And "Closed-mode shadow roots are not
supported."
So getByRole, getByText, getByLabel and a plain CSS
selector all reach through an open root. docs/other-locators states the CSS half
positively: "CSS selectors pierce open shadow DOM."
The samples below ran in Chrome against Playwright 1.62 before this page shipped, and the caption under each one says what the run did.
import { test, expect } from '@playwright/test';
const COMPONENT = `
<x-summary></x-summary>
<script>
customElements.define('x-summary', class extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' }).innerHTML =
'<button>Show breakdown</button><p>Subtotal 42.00</p>';
}
});
</script>`;
test('an open shadow root needs nothing special', async ({ page }) => {
await page.setContent(COMPONENT);
// Both of these are inside the shadow root. Neither call mentions it.
await page.getByRole('button', { name: 'Show breakdown' }).click();
await expect(page.getByText('Subtotal 42.00')).toBeVisible();
// The one locator that stops at the boundary.
expect(await page.locator('//button[text()="Show breakdown"]').count()).toBe(0);
});
Playwright 1.62 · TypeScript · passes
The last line is the first exception doing its work: the same button, described in XPath, matches nothing.
If you are used to reaching for shadowRoot through an injected script, that
helper has nothing to do here. The locator you would write for an ordinary button is the locator
that works.
Content a component receives through a <slot> never moves into the shadow
root, so it was always reachable. In our run getByText found the slotted text and
a heading inside the root itself with the same call.
The locator ranking says which of the built-in locators
to reach for first.
How do you click something inside an iframe?
docs/frames gives the reason before it gives the syntax: "A Page can have one
or more Frame objects attached to it. Each page has a main frame and page-level interactions
(like click) are assumed to operate in the main frame." Your locator is looking in a
different document from the one holding the button.
import { test, expect } from '@playwright/test';
const CHECKOUT = `
<h1>Checkout</h1>
<iframe id="card-frame" title="Card details"
srcdoc='<label for="number">Card number</label>
<input id="number">
<button>Pay</button>'></iframe>`;
test('the same locator, unscoped and scoped', async ({ page }) => {
await page.setContent(CHECKOUT);
// The locator you would write for any other button. The button is on the screen
// and this matches nothing, because it is in a different document. Written as an
// action instead of a count, it waits and then reports that it never found it.
expect(await page.getByRole('button', { name: 'Pay' }).count()).toBe(0);
// Name the frame first and the identical locator resolves.
await page.frameLocator('#card-frame').getByRole('button', { name: 'Pay' }).click();
});
Playwright 1.62 · TypeScript · passes
page.frameLocator() takes a CSS selector for the <iframe>
element itself, and everything chained after it is scoped to what is inside. When the frame is
easier to identify by something else — a title, a role, the text next to it —
find the iframe element with any locator you like and convert it.
import { test, expect } from '@playwright/test';
const CHECKOUT = `
<h1>Checkout</h1>
<iframe id="card-frame" title="Card details"
srcdoc='<label for="number">Card number</label>
<input id="number">
<button>Pay</button>'></iframe>`;
test('find the iframe element the way you find anything else', async ({ page }) => {
await page.setContent(CHECKOUT);
// getByTitle returns a Locator for the <iframe> element itself.
// contentFrame() turns that into a FrameLocator you can work inside.
const card = page.getByTitle('Card details').contentFrame();
await card.getByLabel('Card number').fill('4242 4242 4242 4242');
await expect(card.getByLabel('Card number')).toHaveValue('4242 4242 4242 4242');
});
Playwright 1.62 · TypeScript · passes
locator.contentFrame() and page.frameLocator() hand back the same
kind of object. Use the second where a CSS selector describes the frame well and the first
where it does not.
frameLocator or the frame API — which to reach for?
A FrameLocator is a description of a frame, re-resolved every time you use it. A
Frame is a handle to a frame that is attached right now. It is the same distinction as a locator against an element
handle, and every row below follows from it.
page.frameLocator() / locator.contentFrame() | page.frame() / page.frames() | |
|---|---|---|
| What you get back | A FrameLocator | A Frame, or null |
| When it resolves | When you act on a locator inside it | At the moment you call it |
| The frame has not loaded yet | The action waits, then times out | You get null |
| The frame is re-created | Resolved again on the next use | The handle you kept is detached |
| Two iframes match | Throws — frame locators are strict | Returns the first frame matching the name or url given |
| Reach for it when | You are acting on an element inside the frame | You need the frame's own url or name, or the tree |
Row five is easy to guess wrong from the documentation, so we ran it: on a page holding two
frames whose URLs both matched, page.frame({ url: /widget\.html/ }) returned the
first of them and threw nothing.
The frame API is for a narrow set of jobs. frame.url() and
frame.name() answer questions about the frame itself rather than about something
inside it. childFrames() and parentFrame() walk the tree, and
page.frames() prints everything attached, which is the quickest way to see the
shape of markup you did not write.
One thing never comes up, and people arriving from a driver-based framework keep waiting for
it: there is nothing to switch
back from. Scoping applies to one chain of calls, so page.getByRole(...) on the
next line is still describing the main frame.
The five iframe failures that cost a day
1. The frame is not there yet
page.frame() answers at the moment you call it, and if the iframe has not been
attached the answer is null. What breaks next is your own code dereferencing that
null, which is why this one reads as a bug in the test rather than as anything to do with the
page. A frame locator has no such moment, because the action inside it waits like every other
action. Playwright's note on frame.waitForLoadState() says as much: "Most of
the time, this method is not needed because Playwright auto-waits before every action."
Auto-waiting and timeouts set out what
Playwright waits for and which clock runs out.
2. Two frames answer to one selector
Frame locators are strict, in the same way locators are.
docs/api/class-framelocator: "Frame locators are strict. This means that all
operations on frame locators will throw if more than one element matches a given
selector." The error prints both frames and a suggested locator beside each one.
Then the part that is easy to miss because it is recent: first(),
last() and nth() on FrameLocator are deprecated, and the
replacement the documentation names is locator.nth() followed by
locator.contentFrame(). The deprecation notice ships in the 1.62 type
definitions, so your editor will tell you before your pipeline does.
import { test, expect } from '@playwright/test';
const TWO_WIDGETS = `
<iframe class="widget" title="Discount code" srcdoc='<button>Apply</button>'></iframe>
<iframe class="widget" title="Gift card" srcdoc='<button>Apply</button>'></iframe>`;
test('two frames answer to one selector', async ({ page }) => {
await page.setContent(TWO_WIDGETS);
// Frame locators are strict, so this throws instead of taking the first match.
await expect(
page.frameLocator('iframe.widget').getByRole('button', { name: 'Apply' }).click()
).rejects.toThrow('strict mode violation');
// The documented replacement for the deprecated frameLocator().nth().
await page.locator('iframe.widget').nth(1).contentFrame()
.getByRole('button', { name: 'Apply' }).click();
// What the index was standing in for. Nothing here breaks when a third widget lands.
await page.getByTitle('Gift card').contentFrame()
.getByRole('button', { name: 'Apply' }).click();
});
Playwright 1.62 · TypeScript · passes
An index is a retreat. A frame with a name or a stable title is the
repair, and the last two lines of that block are the difference between them.
3. The frame is replaced while you are holding it
A single-page application re-renders the region, the browser tears down the old iframe and
builds a new one, and the Frame you captured points at something that has gone. We
reproduced it against our own page: capture the frame with page.frame('region'),
click the control that re-renders the region, then use the handle again.
locator.click: Frame was detached
Call log:
- waiting for getByRole('button', { name: 'Pay' })
Playwright 1.62 · the error printed by the spec described above
frame.isDetached() returns true from that point and stays true; a
frame is detached once and does not come back. A FrameLocator written against the
same iframe keeps working, because it resolves again on the next call.
4. The frame you want is inside another frame
import { test, expect } from '@playwright/test';
const NESTED = `
<iframe id="outer" name="address-step"></iframe>
<script>
const inner = document.createElement('iframe');
inner.id = 'inner';
inner.name = 'address-lookup';
inner.srcdoc = '<button>Confirm address</button>';
document.getElementById('outer').srcdoc = inner.outerHTML;
</script>`;
test('chain one frame locator into the next', async ({ page }) => {
await page.setContent(NESTED);
await page
.frameLocator('#outer')
.frameLocator('#inner')
.getByRole('button', { name: 'Confirm address' })
.click();
// page.frames() is flat: every frame attached to the page, at any depth.
expect(page.frames().map(frame => frame.name()))
.toEqual(['', 'address-step', 'address-lookup']);
});
Playwright 1.62 · TypeScript · passes
Chain outer to inner and keep going as deep as the markup does. page.frames() is
flat — every frame on the page at any depth, in one array, with no tree to walk — and when the
markup is not yours, printing that list beats reading the page source.
5. The frame belongs to somebody else's origin
This is the failure people expect and mostly it is not one. The next section says which part of it holds and which part belongs to the browser rather than to Playwright.
What Playwright will not do here
Closed shadow roots
The documentation is flat about it: "Closed-mode shadow roots are not supported." We
built one. A component that calls
attachShadow({ mode: 'closed' }) leaves you a host element that is visible, has an
empty innerText, and contains nothing any locator can describe.
getByRole, getByText and a CSS selector on the id inside all returned
zero matches, and evaluating document.querySelector('x-sealed').shadowRoot in the
page returned null. In the same run the open-rooted component beside it answered
normally, so the query engine was working.
The only thing on the other side of that wall is a change to the application. Patching
Element.prototype.attachShadow in an init script so the root is created open does
make the locator work — we ran that too — and it also means the component in the browser is no
longer the component you ship. So the assertion has to move: onto the host element, onto what
the component does to the rest of the page, or onto the request it makes.
XPath, at any shadow root
"XPath does not pierce shadow roots." Both docs/locators and
docs/other-locators carry that sentence. It matters most on a migration. XPath expressions aimed at a component library stop at
every root, however carefully they are written, so those files get rewritten instead of
converted — better known before the estimate than during it.
Cross-origin frames
Three finished reads of the frame documentation — docs/frames,
docs/api/class-frame and docs/api/class-framelocator — condition
nothing on a frame's origin, and our run agrees with them. Against a frame served from a second
origin, frameLocator filled an input, clicked a button, held a
toHaveText assertion and chained into a frame nested inside it;
page.frame({ url }) returned the frame, frame.evaluate() ran in it,
and parentFrame() came back as the main frame.
The boundary is still there and it belongs to the browser: reading
contentDocument off the iframe element from the parent page returns
null. Two ports on one machine is also the mildest cross-origin case there is, and
a vendor's frame arrives with a sandbox attribute, a registrable domain of its own
and a content security policy, none of which were in our reproduction.
The payment widget you do not own
Playwright's own advice comes first, from docs/best-practices, under Avoid
testing third-party dependencies: "Only test what you control. Don't try to test links
to external sites or third party servers that you do not control."
Somebody else's card form runs on somebody else's release schedule. A spec that types into it passes for eight months and fails the morning they ship a new one, inside your pipeline.
Three things can be done about it, each giving something up.
Stub the request and assert what your own page does with the answer. This is what the documentation recommends and it is the sane default.
import { test, expect } from '@playwright/test';
const CHECKOUT = `
<button>Place order</button>
<p id="status">Not paid</p>
<script>
document.querySelector('button').addEventListener('click', async () => {
const res = await fetch('https://payments.example/charge', { method: 'POST' });
const body = await res.json();
document.getElementById('status').textContent =
body.status === 'confirmed' ? 'Payment confirmed' : 'Card declined';
});
</script>`;
test('our page does the right thing with a confirmed charge', async ({ page }) => {
await page.route('https://payments.example/charge', route => route.fulfill({
status: 200,
contentType: 'application/json',
headers: { 'access-control-allow-origin': '*' },
body: JSON.stringify({ status: 'confirmed' }),
}));
await page.setContent(CHECKOUT);
await page.getByRole('button', { name: 'Place order' }).click();
await expect(page.getByText('Payment confirmed')).toBeVisible();
});
Playwright 1.62 · TypeScript · passes
You give up any signal about the integration: this spec stays green on the day the vendor
changes their API. How much of the rest of your network to replace is a longer argument with
two respectable sides, and it is not settled
here either. The mechanics of page.route()
belong to the article on intercepting
requests.
Test up to the boundary. Assert that the iframe is on the page and configured the way your code configured it, with the right source, amount and currency, then drive your own success and failure paths through the stub. The vendor's own rendering goes untested.
Keep one live run against the vendor's sandbox, outside the pipeline that gates a deploy. It will be the least reliable spec you own and it needs a named owner. It buys the one thing neither of the others can: notice, on some schedule, that the integration still works.
One question we settled by running it, because docs/network names popup windows
and opened links and says nothing about frames: page.route() did intercept a
request made by the cross-origin subframe in our application. A stub reaches the vendor's frame
as well as your own page.
Deciding what a suite covers at the boundary of software you do not own is a suite-design decision, and a team either settles it once or re-argues it every sprint. It belongs with the other conventions that get written down while the suite and the runbook are built together.
One risk the stub cannot remove: a payment form is exactly the kind of thing that behaves differently on a real device, and our WebKit build is not the Safari that ships on an iPhone. The documentation says why — "Playwright doesn't work with the branded version of Safari since it relies on patches." An iOS pass on a checkout is therefore a manual check, or it does not happen.
Questions
How do I click a button inside an iframe in Playwright?
Scope the locator to the frame, then locate inside it: page.frameLocator('#card-frame').getByRole('button', { name: 'Pay' }).click(). When the iframe is easier to identify by something other than a CSS selector, find the iframe element with any locator you like and convert it with locator.contentFrame(). Nothing has to be switched back afterwards. All of this is a document in a browser, so a WebView inside a native iOS or Android app is outside what Playwright drives.
Does Playwright work with shadow DOM?
Yes for open shadow roots, and no setting turns it on. The documentation says: "All locators in Playwright by default work with elements in Shadow DOM. The exceptions are: Locating by XPath does not pierce shadow roots. Closed-mode shadow roots are not supported." A role, a text or a CSS query therefore resolves inside an open root. A closed root is a wall: in our own run against Playwright 1.62 every locator aimed inside one returned zero matches, and the host's shadowRoot property read as null.
Can Playwright handle a cross-origin iframe?
In our own run against Playwright 1.62 it did: with a frame served from a second origin, frameLocator filled an input, clicked a button, held an assertion and chained into a frame nested inside it, and page.frame({ url }) returned the frame. Nothing in the frame documentation conditions any method on origin either. Our reproduction was two ports on one machine, so it says nothing about a sandbox attribute, a vendor's cookie rules or a content security policy.
What is the difference between page.frame() and page.frameLocator()?
page.frame() returns a Frame or null at the moment you call it, so a frame that has not attached yet gives you null and a frame the application re-renders leaves you holding a detached handle. page.frameLocator() returns a description that resolves when you act on something inside it, so it waits for a late frame and resolves again after a re-render. Reach for the frame API when you want the frame's own url, name or tree, and for a frame locator when you want to act on an element inside.
Which of your specs depends on a form nobody at your company wrote?
One element behind one frame is a fix for this afternoon. A checkout that has to pass on every release, through a card form somebody else ships on their own schedule, is a decision about the whole suite, and it ends up written into the suite in your repository, the job that runs it, and the runbook for the team that inherits it. Send us the spec that goes red the morning the vendor deploys.