Working with Iframes in Playwright

Why normal locators fail inside iframes

An iframe embeds a complete, separate document inside your page. It has its own DOM tree, its own scripts, and often its own origin. When you write page.getByRole('textbox'), Playwright searches the main document only. Content inside an iframe is invisible to that search, so the locator times out even though you can see the element right there in the browser.

This is the single most common reason a locator that looks correct never resolves. The element exists, the selector is fine, but it lives in a different document.

Selenium solved this with switchTo().frame(), a stateful command that changed which document later calls would target. Playwright takes a different approach: you build a locator that is scoped to the frame, and nothing else changes.

frameLocator scopes a search to a frame

page.frameLocator() takes a selector that matches the <iframe> element and returns a FrameLocator. Every locator you chain on it searches inside that frame's document:

const checkout = page.frameLocator('#checkout-frame');

await checkout.getByLabel('Cardholder name').fill('Jane Doe');
await checkout.getByRole('button', { name: 'Pay now' }).click();

All the built-in locators work on a FrameLocator: getByRole, getByLabel, getByText, getByTestId, plus raw CSS through locator(). Auto-waiting works too. If the iframe has not attached yet when the action runs, Playwright waits for the frame to appear, for its document to load, and for the element inside it to be actionable.

Since Playwright 1.43 there is an equivalent spelling, locator.contentFrame(), which converts a normal locator pointing at an iframe element into a FrameLocator:

await page.locator('#checkout-frame').contentFrame()
  .getByRole('button', { name: 'Pay now' }).click();

Both forms do the same thing. Recent versions of codegen emit contentFrame(), older ones emit frameLocator(), and you can use whichever reads better to you.

Nested iframes

Frames can contain frames. A payment provider inside a checkout widget inside your page is three documents deep. Chain frame locators to walk down the hierarchy:

await page
  .frameLocator('#checkout-widget')
  .frameLocator('iframe[name="payment"]')
  .getByRole('textbox', { name: 'Card number' })
  .fill('4242424242424242');

Each frameLocator call scopes the next search one level deeper. There is no limit to the chain, and auto-waiting applies at every level, so a nested frame that loads late does not need any special handling.

Real-world example: Stripe card fields

Stripe Elements is the classic iframe scenario. With the split card fields, each input renders in its own iframe, so a single frameLocator is not enough. Stripe gives each iframe a stable title attribute, which its documentation lists, and that is the right hook:

test('completes checkout with a test card', async ({ page }) => {
  await page.goto('/checkout');

  await page
    .frameLocator('iframe[title="Secure card number input frame"]')
    .getByPlaceholder('Card number')
    .fill('4242424242424242');

  await page
    .frameLocator('iframe[title="Secure expiration date input frame"]')
    .getByPlaceholder('MM / YY')
    .fill('12/34');

  await page
    .frameLocator('iframe[title="Secure CVC input frame"]')
    .getByPlaceholder('CVC')
    .fill('123');

  await page.getByRole('button', { name: 'Pay' }).click();
  await expect(page.getByText('Payment successful')).toBeVisible();
});

The number 4242 4242 4242 4242 is Stripe's standard test card, valid in test mode with any future expiry and any CVC. If your integration uses the newer Payment Element instead of split card fields, the structure differs but the technique is identical: find the iframe's title in DevTools, build a frameLocator on it, and fill by placeholder or label.

Avoid targeting these iframes by index or by generated class names. Stripe rewrites its internal markup regularly, while the title attributes are part of its accessibility surface and stay stable.

Waiting for an iframe to load

A frequent mistake is waiting for the iframe element and assuming its content is ready:

// Anti-pattern: proves the tag exists, not that the content loaded
await expect(page.locator('#checkout-frame')).toBeVisible();
await page.frameLocator('#checkout-frame').getByRole('button').click();

The <iframe> tag can be visible while its document is still a blank page. Assert on content inside the frame instead:

// Recommended: the assertion retries until the frame content is really there
const checkout = page.frameLocator('#checkout-frame');
await expect(checkout.getByRole('button', { name: 'Pay now' })).toBeVisible();

Because web-first assertions retry, this single line covers frame attachment, document load, and element rendering. You rarely need anything more explicit than that.

When you need the Frame object

FrameLocator covers clicking, filling, and asserting, which is almost everything. For the remaining cases, Playwright exposes the lower-level Frame object through page.frames() and page.frame():

const frame = page.frame({ url: /checkout\.example\.com/ });
if (frame === null) throw new Error('checkout frame not found');

const total = await frame.evaluate(() => {
  return (window as any).__CHECKOUT_STATE__.total;
});

Reach for Frame when you need to run JavaScript inside the frame with frame.evaluate(), inspect frame.url(), or enumerate every frame on the page with page.frames() for debugging. Unlike FrameLocator, a Frame is a live handle: it points at a specific frame instance and becomes useless if that frame navigates or detaches. For interactions, stick with frameLocator, which re-resolves every time.

Third-party widgets: assert presence, skip the interaction

Chat bubbles, video embeds, ad slots, and cookie managers all arrive as iframes. You could drive them with frameLocator, but usually you should not. You do not control their markup, their release cycle, or their A/B tests, so interactions with them make your suite fail for reasons unrelated to your code.

For these, assert that the integration loaded and stop there:

await expect(page.locator('iframe[title="Live chat"]')).toBeAttached();

That verifies your embed code ran and the third party responded, which is the part you own. Testing that the chat vendor's send button works is the vendor's job.

Cross-origin limits

Playwright's frame handling works across origins, so frameLocator can click and fill inside an embedded document from another domain. Two limits remain. First, page.evaluate() on the parent page cannot reach into a cross-origin frame's JavaScript context, because the browser's same-origin policy applies to your script like any other. Use frame.evaluate() on the frame itself instead. Second, some third-party frames, payment providers and CAPTCHA widgets in particular, detect automation and change behavior or block input. When that happens, test against the provider's official test mode rather than fighting the detection.

Codegen records frames for you

You do not need to reverse-engineer frame structure by hand. Run codegen, click the element inside the iframe, and the recorder emits the full chain, including nested frames, with the same locator priorities it uses on the main page. This is the fastest way to discover which title or name attribute identifies a frame.

Common errors and what they mean

A locator that times out with "waiting for element" even though the element is on screen almost always means the element is inside an iframe and your locator searches the main page. Move it onto a frameLocator.

A "frame was detached" error means the frame was destroyed while Playwright interacted with it. This happens when the page navigates mid-test, when a single-page app re-renders the component that owned the iframe, or when a payment provider swaps its frame after tokenization. If the detach is expected, wait for the state that follows it, such as a confirmation message on the main page, instead of touching the old frame again. If it is not expected, check whether an earlier action triggered a navigation you did not account for, and see the waits and timeouts guide for how to wait on the new state properly.

Run it in production

Checkout flows built on iframes are exactly the flows you want watched after deploy, because a payment provider change can break them without any commit on your side. Hyperping Browser Checks run your Playwright scripts on a schedule from multiple regions, so the same frameLocator chains that pass in CI keep verifying your live checkout.

Get started

Start monitoring in the next 5 minutes.

Stop letting customers discover your outages first. Set up monitoring, status pages, on-call, and alerts before your next coffee break.

14 days free trial. No card required.