Locators and selectors
Playwright iframes: frameLocator, Payment Forms and Nested Frames
How to reach elements inside iframes in Playwright, including card fields hosted by a payment provider.

A Playwright iframe test fails in a confusing way the first time you write one. The card number field is right there on the screen, yet page.getByLabel('Card number') waits and then times out. The field lives in another document, inside an <iframe>, and page-level locators never look there. You have to enter the frame first.
This guide shows how to do that with frameLocator and contentFrame(), then covers the cases that make iframes hard in practice: card fields hosted by a payment provider, a bank's verification frame nested inside the card frame, frames from another origin, frames that a script mounts late, and the errors you get when something is off. Every example ran against a small local checkout with three origins (no real payment provider is involved), with @playwright/test 1.61.1 and 1.64.1 on Chromium (one section needs 1.63 or later, and says so). Download the examples to run them yourself: the fixture checkout (plain Node, no dependencies), the config and every spec. The specs are complete as shown; the config sets baseURL: 'http://localhost:4310' and starts the fixture server through webServer. In your own project, that webServer command becomes your app's start command.
Key takeaways
- Page locators stop at frame boundaries. Enter each frame with
page.frameLocator(selector)orlocator.contentFrame(), then use the usualgetByRoleandgetByLabelinside it. - Nested frames: chain one frame locator per level, or on Playwright 1.63+ call
page.frameLocator()with no selector to search every frame. - Cross-origin frames work the same way. Playwright drives every frame directly, even though your page's own JavaScript can't read them.
- Prefer a
FrameLocatorover aFrameobject. It waits for the frame and survives the iframe being replaced; aFrameyou stored doesn't. - Fill card fields with your provider's published test numbers only, and keep traces private: they record the values you type.
frameLocator in one minute
The fixture checkout has a small "Order updates" form in a same-origin iframe:
<iframe title="Order updates" src="/updates.html" height="110"></iframe>page.frameLocator() takes a selector for the <iframe> element and gives you a FrameLocator. Everything you locate from it is searched inside that frame's document:
import { test, expect } from '@playwright/test';
test('subscribes from the embedded form', async ({ page }) => {
await page.goto('/checkout.html');
const updates = page.frameLocator('iframe[title="Order updates"]');
await updates.getByLabel('Email').fill('ana@example.com');
await updates.getByRole('button', { name: 'Send me updates' }).click();
await expect(updates.getByRole('status')).toHaveText('Subscribed');
});There is no "switch to frame" step, as there is in Selenium. The frame is part of the locator, so each line says where it acts, and the next line can go back to the page by starting from page again.
contentFrame() and owner()
Since Playwright 1.43 you can also start from an ordinary Locator that points at the <iframe> and call contentFrame(). This reads well when you already locate the frame element with a user-facing locator such as getByTitle:
import { test, expect } from '@playwright/test';
test('the same frame, reached with contentFrame()', async ({ page }) => {
await page.goto('/checkout.html');
const frameElement = page.getByTitle('Order updates'); // a Locator for the <iframe> itself
await expect(frameElement).toBeVisible();
const updates = frameElement.contentFrame(); // a FrameLocator for what is inside it
await updates.getByLabel('Email').fill('ana@example.com');
await updates.getByRole('button', { name: 'Send me updates' }).click();
await expect(updates.getByRole('status')).toHaveText('Subscribed');
// owner() goes the other way, from the FrameLocator back to the <iframe>.
await expect(updates.owner()).toHaveAttribute('src', '/updates.html');
});Both forms produce the same thing. In call logs and error messages, Playwright prints a frame step as .contentFrame() whichever one you wrote.
Choosing the frame selector
The selector for the <iframe> follows the same rule as any locator: pick what is least likely to change. In rough order:
title. An iframe should have one for screen readers anyway (MDN explains why), and it describes the content:getByTitle('Order updates').nameoridthat your team or the provider documents:#payment,iframe[name="payment"].- Part of the
srcwhen nothing else is stable:iframe[src*="/card.html"]. Match a path, not the full URL, because query strings often carry session ids. - Position, such as
page.locator('iframe').nth(1).contentFrame(), only as a last resort. It breaks the day someone adds a chat widget. (Callfirst()ornth()on the iframeLocator, thencontentFrame(): the same methods on aFrameLocatorare deprecated.)
The same ranking for elements inside the frame is in Playwright locators.
Nested frames
Payment flows often put a frame inside a frame. In the fixture, the provider's card frame (#payment) opens a bank verification frame inside itself when the card needs an extra check. The checkout page and the two frames each come from a different origin.
A frame locator with a selector enters exactly one level. For the next level, call frameLocator() (or contentFrame()) on the frame locator you already have:
import { test, expect } from '@playwright/test';
test('completes the bank check in a frame inside the card frame', async ({ page }) => {
test.skip(!process.env.TEST_CARD_NUMBER, 'Set TEST_CARD_NUMBER to a test card number');
await page.goto('/checkout.html?challenge=1');
const payment = page.frameLocator('#payment');
await payment.getByLabel('Card number').fill(process.env.TEST_CARD_NUMBER!);
await payment.getByLabel('Expiry date').fill('12 / 34');
await payment.getByLabel('Security code').fill('123');
await page.getByRole('button', { name: 'Pay €42.00' }).click();
// Chain one frame locator per level: page -> card frame -> bank frame.
const bank = payment.frameLocator('iframe[title="Bank verification"]');
await bank.getByLabel('Verification code').fill('123456');
await bank.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByRole('status')).toHaveText(/^Payment approved/);
await expect(payment.getByTitle('Bank verification')).toHaveCount(0); // the bank frame is gone
});The bank frame doesn't exist when the test starts. It appears only after Pay is clicked. You don't need to wait for it: like every locator, the chain is resolved when an action runs, and the action waits until each frame and the element inside it are there.
The same chain with contentFrame() reads like this fragment:
// Fragment: the same chain, built from Locators
const payment = page.locator('#payment').contentFrame();
const bank = payment.getByTitle('Bank verification').contentFrame();
await bank.getByLabel('Verification code').fill('123456');Two things to keep in mind. First, page.frameLocator('iframe[title="Bank verification"]') on its own finds nothing, because that iframe isn't in the page's document; it is in the card frame's document. Second, keep the outer frame locator in a variable, as above. When the markup changes, you fix one line.
Search every frame (Playwright 1.63+)
Playwright 1.63 added a shortcut, listed as "Locate across frames" in the release notes. Call page.frameLocator() with no selector and the rest of the locator is searched in every frame on the page, nested ones included. You don't name the iframe at all:
import { test, expect } from '@playwright/test';
test('reaches the card and bank frames without naming them', async ({ page }) => {
test.skip(!process.env.TEST_CARD_NUMBER, 'Set TEST_CARD_NUMBER to a test card number');
await page.goto('/checkout.html?challenge=1');
const anyFrame = page.frameLocator(); // no selector: every frame on the page
await anyFrame.getByLabel('Card number').fill(process.env.TEST_CARD_NUMBER!);
await anyFrame.getByLabel('Expiry date').fill('12 / 34');
await anyFrame.getByLabel('Security code').fill('123');
await page.getByRole('button', { name: 'Pay €42.00' }).click();
// The bank frame sits inside the card frame. No chain needed.
await anyFrame.getByLabel('Verification code').fill('123456');
await anyFrame.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByRole('status')).toHaveText(/^Payment approved/);
});frame.frameLocator() with no selector does the same for one frame and everything inside it. In Python it is page.frame_locator(). The located element must still sit in a single frame. When it matches in more than one, the action fails at once. In the fixture, the checkout page and the "Order updates" frame both have a status element, so page.frameLocator().getByRole('status').click() fails on 1.64.1 with:
Error: locator.click: frameLocator() matched elements in multiple frames.
Call log:
- waiting for frameLocator().getByRole('status')That is the trade-off. The selector-less form is shorter and doesn't care how deep a frame is nested. But it no longer says which frame you mean, and a test that passes today breaks the day another frame, such as a chat widget, gains an element with the same label or role. Explicit selectors document the frame and keep working on versions before 1.63. A reasonable split is the shortcut for a unique field deep inside third-party frames, and named frames everywhere else.
Payment card fields in iframes
Hosted card fields are the most common iframe in end-to-end tests. Providers put the card number, expiry and security code in a frame they serve, so the raw card data never touches your page. For a test, that has three consequences.
Only the card fields are in the frame. Your own page still owns the Pay button, the order total and the confirmation. Mixing them up is a common bug in a test: the click is on page, the fields are on the frame locator.
import { test, expect } from '@playwright/test';
// A provider's published test card, never a real one. Set it in CI as a variable:
// TEST_CARD_NUMBER=4242424242424242 npx playwright test
const cardNumber = process.env.TEST_CARD_NUMBER;
test('pays with a test card in the provider frame', async ({ page }) => {
test.skip(!cardNumber, 'Set TEST_CARD_NUMBER to a test card number');
await page.goto('/checkout.html');
const payment = page.frameLocator('#payment');
await payment.getByLabel('Card number').fill(cardNumber!);
await payment.getByLabel('Expiry date').fill('12 / 34');
await payment.getByLabel('Security code').fill('123');
// The Pay button belongs to your page, not to the provider's frame.
await page.getByRole('button', { name: 'Pay €42.00' }).click();
await expect(page.getByRole('status')).toHaveText(`Payment approved: card ending ${cardNumber!.slice(-4)}`);
});
test('shows the provider error for an invalid number', async ({ page }) => {
await page.goto('/checkout.html');
const payment = page.frameLocator('#payment');
await payment.getByLabel('Card number').fill('4242 4242 4242 4241'); // fails the Luhn check
await payment.getByLabel('Expiry date').fill('12 / 34');
await payment.getByLabel('Security code').fill('123');
await page.getByRole('button', { name: 'Pay €42.00' }).click();
await expect(payment.getByRole('alert')).toHaveText('Your card number is invalid.');
await expect(page.getByRole('status')).toHaveText('Check your card details');
});The second test asserts in both places: the provider's message inside the frame, and your page's reaction outside it. Those are separate pieces of code, owned by different teams, and either can break.
Use test card numbers only. Providers publish numbers for their test mode. Stripe, for example, lists 4242 4242 4242 4242 with any future expiry and any CVC in its testing docs, and says not to use real card details. Read the number from an environment variable rather than writing it into the spec. That keeps one place to change it per environment and stops anyone pasting a real card into a test file. If you run tests against several environments, Playwright environment variables covers how to load them.
The example skips when the variable is missing, which suits a laptop. In CI you usually want the opposite, so a missing variable fails the run loudly instead of skipping the payment tests unnoticed. One way is to throw from playwright.config.ts when process.env.CI is set and the variable isn't.
Traces record what you type. A Playwright trace stores each fill call with its value. We checked: the trace of the test above contains the full test card number in plain text. A test card isn't a secret, but the same holds for passwords and tokens, so treat trace and report artifacts as private and keep them only as long as you need them. Playwright Trace Viewer covers how to collect traces in CI.
Check what your provider allows
Some providers block automation in their hosted fields. Stripe's automated testing page says Checkout and the Payment Element have security measures that prevent automated testing, and suggests mocking their responses in your tests instead. So before you build a suite around a provider's frame, read its testing docs. The usual options are:
- Drive the provider's test mode where it is allowed, for one or two end-to-end checkout tests.
- Swap in a stub frame in your test environment that behaves like the provider's (the fixture in this post is such a stub), and test your page's handling of approvals, declines and verification against it.
- Mock at the network or server boundary for decline codes and other error states. Playwright network mocking shows how.
If your provider puts each field in its own iframe, you get one frame locator per field. Look at the iframes' title or name in the provider's markup and target each one. This fragment uses made-up titles:
// Fragment: one iframe per field. Check the real titles in your provider's markup.
await page.frameLocator('iframe[title="Secure card number input"]').getByRole('textbox').fill(cardNumber);
await page.frameLocator('iframe[title="Secure expiration date input"]').getByRole('textbox').fill('12 / 34');Cross-origin frames
In the fixture, the checkout runs on http://localhost:4310, the card frame on http://127.0.0.1:4311 and the bank frame on http://localhost:4312. A different port is enough to make a different origin. The tests above didn't need anything special for that, and that is the main point of this section: frameLocator works the same for same-origin and cross-origin frames.
What changes is what your page's own JavaScript can do. The same-origin policy hides a cross-origin frame's document from the parent page. Playwright isn't the parent page, so it can still run code in the frame. This test shows both sides:
import { test, expect } from '@playwright/test';
test('the page cannot read a cross-origin frame, Playwright can', async ({ page }) => {
await page.goto('/checkout.html');
await expect(page.frameLocator('#payment').getByLabel('Card number')).toBeVisible();
// Same-origin policy: from the shop's own script, the card frame's document is off limits.
const fromThePage = await page.evaluate(
() => (document.querySelector('#payment') as HTMLIFrameElement).contentDocument,
);
expect(fromThePage).toBeNull();
// Playwright drives every frame directly, whatever its origin.
const cardFrame = page.frame({ url: /127\.0\.0\.1:4311\/card\.html/ });
expect(cardFrame).not.toBeNull();
expect(await cardFrame!.evaluate(() => location.origin)).toBe('http://127.0.0.1:4311');
expect(cardFrame!.parentFrame()).toBe(page.mainFrame());
});So the rule is simple: don't reach into frames with page.evaluate and contentDocument. That only works for same-origin frames, and it skips Playwright's waiting. Use a frame locator for actions and assertions. If you really need to run a script in the frame, get its Frame and call evaluate on that.
This is one place where Playwright and Cypress differ. Cypress's cross-origin guide says, as of October 2026, that it can't automate cross-origin iframes such as payment forms, and its workaround (chromeWebSecurity: false) works only in Chrome-based browsers. Cypress vs Playwright compares the two tools in more depth.
Frame objects, and waiting for a frame
page.frame() finds a Frame by its name attribute or its URL. page.frames() lists them all, which helps when you debug which frames a page really has. A Frame is useful for evaluate, frame.url() and walking parentFrame() and childFrames().
Unlike a locator, page.frame() doesn't wait. In the fixture, the card frame is mounted by a script about 400 ms after load, the way a provider's JavaScript SDK adds its card frame once the page is running. Right after goto, page.frame({ name: 'payment' }) usually returns null. If you need the Frame, poll for it:
import { test, expect } from '@playwright/test';
test('wait for a frame that a script mounts later', async ({ page }) => {
await page.goto('/checkout.html');
// The card frame is added about 400 ms after load. page.frame() does not wait:
// right after goto it usually returns null.
await expect
.poll(() => page.frame({ name: 'payment' })?.url())
.toContain('/card.html');
const cardFrame = page.frame({ name: 'payment' })!;
await cardFrame.getByLabel('Card number').fill('4242 4242 4242 4241');
await expect(cardFrame.getByLabel('Card number')).toHaveValue('4242 4242 4242 4241');
});The poll checks the URL, not just that the frame exists, because a newly added iframe can briefly hold an about:blank document before its src loads. For actions and assertions you don't need any of this: a frame locator waits on its own. Playwright's auto-waiting rules apply inside frames exactly as on the page; Playwright auto-waiting lists what each action waits for.
Common Playwright iframe errors
Each of these is reproduced in the example code with test.fail(), so the suite stays green. The messages below are the real ones from Playwright 1.61.1, and 1.64.1 prints the same.
Timeout on an element you can see
TimeoutError: locator.fill: Timeout 2000ms exceeded.
Call log:
- waiting for getByLabel('Card number')The test used page.getByLabel('Card number'), but the field is inside an iframe. Nothing in the message mentions frames, which is why it's confusing. Open the trace or the page in the inspector, check whether the element sits under an <iframe>, and add the frame locator. On 1.63 or later, page.frameLocator().getByLabel('Card number') also finds it without naming the frame (see the trade-off above). The demo sets a 2-second action timeout; with the defaults, the wait runs until the test timeout (30 seconds).
Strict mode violation on the iframe
Error: locator.fill: Error: strict mode violation: locator('iframe') resolved to 2 elements:
1) <iframe height="110" src="/updates.html" title="Order updates"></iframe> aka locator('iframe[title="Order updates"]')
2) <iframe id="payment" name="payment" title="Secure payment form" src="http://127.0.0.1:4311/card.html"></iframe> aka locator('iframe[name="payment"]')
Call log:
- waiting for locator('iframe').contentFrame().getByLabel('Email')page.frameLocator('iframe') matched both frames. The call log shows the frame step as .contentFrame(), although the test used frameLocator(). Frame locators are strict, like any locator. The message even suggests better selectors. Note the timing trap: if the second iframe is mounted late, the same test can pass on a fast run and fail on a slow one. Give every frame selector something specific, such as a title, name or id.
expected string, got undefined
Error: locator.fill: value: expected string, got undefinedfill(process.env.TEST_CARD_NUMBER!) with the variable unset. The ! only silences TypeScript; at runtime the value is still undefined. Set the variable in CI and, as described above, fail early when it's missing.
toBeVisible on a FrameLocator
Error: toBeVisible can be only used with Locator object, was called with _FrameLocator _FrameLocator {The message goes on with a long dump of the object. A FrameLocator isn't a Locator, so web-first assertions don't accept it (TypeScript flags this too). To check that the iframe is there, assert on its element with frameLocator.owner() or a normal locator for the <iframe>. To check that its content has loaded, assert on an element inside it.
Frame was detached
Error: locator.fill: Frame was detached
Call log:
- waiting for getByLabel('Card number')The test stored a Frame from page.frame(), then clicked Use a different card, which replaces the iframe. The stored Frame belongs to the old, removed iframe. The same happens whenever your page or an SDK removes an iframe and adds a new one, for example when a checkout lets the customer switch payment method.
A FrameLocator doesn't have this problem, because it is resolved again on every use:
import { test, expect } from '@playwright/test';
test('a FrameLocator survives the iframe being replaced', async ({ page }) => {
await page.goto('/checkout.html');
const payment = page.frameLocator('#payment');
await payment.getByLabel('Card number').fill('4242 4242 4242 4241');
await page.getByRole('button', { name: 'Use a different card' }).click();
// The locator is resolved again on use, so it finds the new frame and its empty field.
await expect(payment.getByLabel('Card number')).toHaveValue('');
await payment.getByLabel('Card number').fill('5555 5555 5555 4444');
await expect(payment.getByLabel('Card number')).toHaveValue('5555 5555 5555 4444');
});This is the strongest reason to default to frame locators and use Frame objects only for what locators can't do.
Iframes in recorded tests (Blendra)
If you record flows instead of writing them by hand, frames are easy for a recorder to miss: a content script that runs only in the top frame never sees them. Blendra's Chrome recorder runs in every frame of the page, so fills inside a provider's card fields, an embedded sign-in form or a booking widget are recorded. It also follows a flow across origins, such as a single sign-on hop or a payment provider's checkout page. Each step inside a frame keeps its frame path: for each level, the iframe's name, id, title or src, or its position as a last resort. The exported Playwright TypeScript test reaches the step with one frameLocator call per level, as in the nested example above.
Card number and security code fields are detected from the field's name, label, placeholder and autocomplete attribute. The value you type is dropped in the browser and never stored, and the exported test reads it from an environment variable such as TEST_CARD_NUMBER. Detection is conservative, not exhaustive, so review the detected fields after recording. Sensitive values lists what is and isn't caught.
The Cypress export (Beta, on Pro+ and Team) has a limit that follows from the section on cross-origin frames: a check on an element inside an iframe can't run in Cypress. Its test is exported skipped, with the step named, and the switch preview lists it under "Checks inside an iframe" before you confirm. For flows that type into a payment provider's frame, use the Playwright TypeScript export: Cypress can't drive cross-origin iframes at all.
FAQ
How do I get the text of an element inside an iframe?
Locate it through the frame and use a web-first assertion: await expect(page.frameLocator('#payment').getByRole('alert')).toHaveText('Your card number is invalid.'). If you need the value as a string, await frameLocator.getByRole('alert').textContent() works too, but it reads once and doesn't retry.
Does page.getByRole search inside iframes?
No. Locators that start from page search the main document only. Start from a frame locator to search inside a frame, and chain another one for each nested level. On Playwright 1.63 or later, page.frameLocator().getByRole(...) with no selector searches every frame on the page at once, as long as the element matches in only one of them.
Can Playwright test cross-origin iframes?
Yes. frameLocator works on cross-origin frames the same way as on same-origin ones, as the payment and bank frames in this guide show. What doesn't work is reaching into the frame from your page's own script with contentDocument, because the browser blocks it.
How do I do this in Playwright for Python?
The methods have snake_case names, and content_frame is a property in Python, not a method:
import os
import pytest
from playwright.sync_api import Page, expect
CHECKOUT = "http://localhost:4310/checkout.html"
@pytest.fixture
def card_number() -> str:
number = os.environ.get("TEST_CARD_NUMBER")
if not number:
pytest.skip("Set TEST_CARD_NUMBER to a test card number")
return number
def test_pays_with_a_test_card(page: Page, card_number: str) -> None:
page.goto(CHECKOUT)
payment = page.frame_locator("#payment")
payment.get_by_label("Card number").fill(card_number)
payment.get_by_label("Expiry date").fill("12 / 34")
payment.get_by_label("Security code").fill("123")
page.get_by_role("button", name="Pay €42.00").click()
expect(page.get_by_role("status")).to_have_text(f"Payment approved: card ending {card_number[-4:]}")
def test_nested_bank_frame(page: Page, card_number: str) -> None:
page.goto(CHECKOUT + "?challenge=1")
payment = page.locator("#payment").content_frame # a property in Python, not a method
payment.get_by_label("Card number").fill(card_number)
payment.get_by_label("Expiry date").fill("12 / 34")
payment.get_by_label("Security code").fill("123")
page.get_by_role("button", name="Pay €42.00").click()
bank = payment.get_by_title("Bank verification").content_frame
bank.get_by_label("Verification code").fill("123456")
bank.get_by_role("button", name="Confirm").click()
expect(page.get_by_role("status")).to_have_text("Payment approved: card ending " + card_number[-4:])Start the fixture server from the download first (node server.mjs), then run pytest. pytest-playwright covers the setup.
Should I use frameLocator or contentFrame()?
Either. They return the same kind of object. page.frameLocator('#payment') is shorter when you have a CSS selector for the iframe. page.getByTitle('Order updates').contentFrame() reads better when you find the iframe with a user-facing locator, or when you already have that locator from another assertion.
