The previous lesson handled separate browser tabs. This one handles the opposite case: a frame inside the same page. Stripe and Braintree render their card-entry forms in iframes so the merchant page never sees the credit-card number directly. OAuth providers embed login forms in iframes during sign-in. Rich-text editors like TinyMCE and CKEditor put the editing surface in an iframe to isolate styles. Embedded chat widgets, reCAPTCHA, YouTube players — all iframes. Where Cypress requires cy.origin or DOM-piercing tricks for these, Playwright has a single first-class API: frameLocator. By the end of this lesson, no iframe — same-origin or cross-origin — should slow you down.
What an iframe actually is
An <iframe> is a complete, separate document embedded inside your page. It has its own DOM, its own JavaScript context, its own URL. From the outside, you see one element on the parent page; from the inside, the iframe is a full HTML document.
Playwright models this with the Frame type. Your top-level page has a main frame; every iframe is a child frame. To act on elements inside the iframe, you need to enter the frame first — that's what frameLocator is for.
frameLocator — the modern API
page.frameLocator(selector) returns a chainable handle scoped to the iframe's document. Every locator method you've used on page works on a frameLocator too:
const stripeFrame = page.frameLocator("#stripe-card-iframe");
await stripeFrame.getByLabel("Card number").fill("4242 4242 4242 4242");
await stripeFrame.getByLabel("Expiry").fill("12/26");
await stripeFrame.getByLabel("CVC").fill("123");
await stripeFrame.getByRole("button", { name: "Pay" }).click();Three things to internalise:
frameLocatoris lazy — it doesn't query the iframe until youawaitan action or assertion. Each action re-resolves the iframe handle, so it survives reloads and dynamic re-renders.- It auto-waits. Same actionability checks as regular locators — visible, stable, enabled. No manual
waitForfor the iframe to load. - Use any iframe selector.
frameLocator('#payment')(CSS),frameLocator('iframe[name="content"]')(attribute),frameLocator('iframe').first()(positional). The selector identifies which iframe to descend into.
Selecting frames — the patterns
Real-world iframes get identified by:
// By id (when the dev set one)
page.frameLocator("#payment-iframe");
// By name attribute (common for SSO and payment iframes)
page.frameLocator("iframe[name='stripe_checkout']");
// By src URL pattern (when only the URL is stable)
page.frameLocator("iframe[src*='stripe']");
// By position (last resort — fragile to layout changes)
page.frameLocator("iframe").first();
page.frameLocator("iframe").nth(2);
// By title attribute (often the most stable for cross-origin iframes)
page.frameLocator("iframe[title='Secure card payment input frame']");For Stripe Elements specifically, the title attribute is the most reliable selector — Stripe sets it deterministically across versions. For other providers, inspect the rendered iframe and pick whichever attribute the team controls.
Nested iframes
When iframes contain iframes (rare in production apps, common in legacy enterprise software), chain frameLocator calls:
const outer = page.frameLocator("#outer-frame");
const inner = outer.frameLocator("#inner-frame");
await inner.getByRole("button", { name: "Submit" }).click();
await expect(inner.getByText("Success")).toBeVisible();Each frameLocator step descends one level. Most apps you test will go at most two deep; three or more is a sign the embedded experience is in trouble for reasons that go beyond your tests.
Asserting on iframe content
Web-first assertions work on iframe locators identically to page locators:
const stripeFrame = page.frameLocator("#stripe-card-iframe");
await expect(stripeFrame.getByText("Payment successful")).toBeVisible();
await expect(stripeFrame.getByLabel("Card number")).toHaveValue(/^4242/);
await expect(stripeFrame.getByRole("button", { name: "Pay" })).toBeEnabled();The retry behaviour is the same — Playwright keeps re-querying the iframe document until the assertion holds or the timeout fires.
Cross-origin iframes — no special handling
This is where Playwright pulls decisively ahead of Cypress. A cross-origin iframe (Stripe lives at js.stripe.com, your app at shop.example.com) is just another iframe to Playwright:
const stripeFrame = page.frameLocator("iframe[name='__privateStripeFrame']");
await stripeFrame.getByPlaceholder("Card number").fill("4242 4242 4242 4242");In Cypress, the same scenario would require cy.origin('js.stripe.com', () => { ... }) and copying credentials across the origin boundary. In Playwright, the cross-origin iframe is identical to a same-origin one. This is a direct consequence of the architecture from Lesson 1 — Playwright drives the browser from outside, so origin boundaries don't constrain it.
A complete Stripe checkout test
A typed test against a hypothetical app with Stripe Elements:
import { test, expect } from "@playwright/test";
test.describe("Checkout with Stripe iframe", () => {
test("completes payment with valid card", async ({ page }) => {
await page.goto("/checkout");
// Parent-page billing form (not in iframe)
await page.getByLabel("Email").fill("alice@test.com");
await page.getByLabel("Postal code").fill("E1 6AN");
// Stripe iframe — descend in
const stripeFrame = page.frameLocator("iframe[title='Secure card payment input frame']");
await stripeFrame.getByPlaceholder("Card number").fill("4242 4242 4242 4242");
await stripeFrame.getByPlaceholder("MM / YY").fill("12 / 26");
await stripeFrame.getByPlaceholder("CVC").fill("123");
// Back to parent — click Pay
await page.getByRole("button", { name: "Pay £29.99" }).click();
// Confirmation lives back on the parent page
await expect(page).toHaveURL(/order-confirmation/);
await expect(page.getByText("Payment successful")).toBeVisible();
});
test("declined card shows iframe error", async ({ page }) => {
await page.goto("/checkout");
const stripeFrame = page.frameLocator("iframe[title='Secure card payment input frame']");
// Stripe's test card that always declines
await stripeFrame.getByPlaceholder("Card number").fill("4000 0000 0000 0002");
await stripeFrame.getByPlaceholder("MM / YY").fill("12 / 26");
await stripeFrame.getByPlaceholder("CVC").fill("123");
await page.getByRole("button", { name: "Pay" }).click();
// Error message renders inside the iframe
await expect(stripeFrame.getByText(/card was declined/i)).toBeVisible();
// And we did NOT navigate away
await expect(page).toHaveURL(/checkout/);
});
});Read each test for the boundary crossings: parent → iframe → parent. The first test fills inside the iframe, then submits and asserts on the parent page. The second test fills inside the iframe, submits, and asserts on the iframe itself (the error renders there). Both flows use the same frameLocator chain — descend, act, assert — and never need any cross-origin gymnastics.
The mental map
- – page.frameLocator(selector)
- – Returns a FrameLocator scoped to the iframe document
- – Lazy — re-resolves on each action
- – iframe[name='...'] — most stable
- – iframe[title='...'] — best for Stripe/cross-origin
- – #id — when the dev sets one
- – .first() / .nth() — last resort
- – All locator methods work — getByRole, getByLabel, etc.
- – All actions work — click, fill, check
- – Web-first assertions auto-retry inside
- Chain frameLocator for nested iframes –
- Cross-origin iframes are first-class — no special API –
- No cy.origin or DOM tricks needed –
When to use frame() instead
The older page.frame(...) API is still supported and useful for one specific case — when you need a Frame handle (not a locator) to call frame-level methods like frame.url() or frame.title():
const stripe = page.frame({ name: "__privateStripeFrame" });
console.log(stripe?.url()); // the URL of the iframe documentFor interacting with elements, prefer frameLocator — it auto-waits and retries; frame() returns a snapshot that can go stale.
A QA tip
When you don't know which iframe selector to use, run codegen against the page (chapter 1, lesson 4) and click into the iframe element. Codegen detects the iframe and generates the frameLocator boilerplate for you. This saves five minutes of inspecting nested DOMs and getting selector attributes wrong.
Coming from Cypress?
The mappings:
cy.get('iframe').its('0.contentDocument.body').then(cy.wrap).find(...)→page.frameLocator('iframe').getByRole(...)cy.iframe()(with thecypress-iframeplugin) →page.frameLocator(...)(built-in)cy.origin('stripe.com', () => { ... })→ justpage.frameLocator('iframe[src*="stripe"]')(no origin gymnastics)
This is the area where many teams' Cypress migration to Playwright is largely a rewrite — the frameLocator API is dramatically cleaner than anything in Cypress. If your Cypress suite has skipped or hand-rolled tests for iframe-heavy flows (Stripe, OAuth, embedded help widgets), those flows often become the easiest to add coverage for after a Playwright migration.
⚠️ Common mistakes
- Using
page.locatorinstead ofpage.frameLocatorfor iframe content.page.locator('input[name=cardNumber]')searches the parent document — the parent has nocardNumberinput, so the locator times out with "no element matched." Always start withpage.frameLocator(...)to descend, then chain locators inside. - Caching a
Framereference and reusing it after a page reload. If you doconst f = page.frame({ name: 'x' })and thenpage.reload(),fis stale. UseframeLocatorinstead — it re-resolves on every action, so reloads are transparent. - Trying to use
page.keyboard.press('Tab')to leave the iframe. Keyboard events go to whichever document is focused. After interacting with an iframe input, focus is inside the iframe; pressing Tab there moves focus inside the iframe. To return focus to the parent, click a parent-page element (await page.getByRole('button', { name: 'Pay' }).focus()) — don't try to "exit" the iframe with keyboard navigation.
🎯 Practice task
Build iframe-handling tests against a public sandbox. 25-30 minutes.
-
Use
https://the-internet.herokuapp.com(public Playwright/Selenium sandbox with iframe scenarios). AddbaseURL: "https://the-internet.herokuapp.com"or hardcode ingoto. -
Create
tests/iframes.spec.ts:import { test, expect } from "@playwright/test"; test.describe("Iframe handling", () => { test("type into the TinyMCE iframe editor", async ({ page }) => { await page.goto("/iframe"); const editor = page.frameLocator("#mce_0_ifr"); await editor.locator("body#tinymce").click(); await editor.locator("body#tinymce").fill("Hello from Playwright"); // Assert the text rendered in the editor body await expect(editor.locator("body#tinymce")).toHaveText("Hello from Playwright"); }); test("nested iframes — three deep", async ({ page }) => { await page.goto("/nested_frames"); // The page splits into top and bottom; top has left/middle/right const top = page.frameLocator("frame[name='frame-top']"); await expect(top.frameLocator("frame[name='frame-middle']").locator("#content")).toHaveText("MIDDLE"); const bottom = page.frameLocator("frame[name='frame-bottom']"); await expect(bottom.locator("body")).toContainText("BOTTOM"); }); test("iframe + parent page — interactions cross the boundary", async ({ page }) => { await page.goto("/iframe"); // Modify text inside iframe const editor = page.frameLocator("#mce_0_ifr"); await editor.locator("body#tinymce").click(); await editor.locator("body#tinymce").fill("Bold this"); // Click "Bold" button on the parent page (toolbar lives outside the iframe) await page.getByRole("button", { name: "Bold" }).click(); // Assert the iframe content now has a <strong> element await expect(editor.locator("body#tinymce strong")).toContainText("Bold this"); }); }); -
Run all three tests across all three browsers.
-
Demonstrate the parent-vs-iframe locator bug. In the first test, change
editor.locator("body#tinymce")topage.locator("body#tinymce"). Run again. The test times out —page.locatordoesn't enter the iframe. This is the single biggest iframe-testing pitfall; once you've felt it, you won't make it again. -
Stretch: find a real iframe on a site you use (Stripe, reCAPTCHA, an embedded YouTube player, an OAuth provider login form). Inspect the iframe's attributes (
name,title,src). Write a Playwright test that descends into it and asserts on at least one element. You won't be able to interact with reCAPTCHA (intentionally — that defeats the bot challenge), but you can assert on its presence and structure. This is the muscle for the day a real test plan needs you to verify a third-party iframe rendered.
That closes Chapter 3 — page navigation, waiting, multi-tab, and iframes. The next chapter shifts focus to the network: intercepting requests, mocking responses, modifying responses on the fly, and the API-testing fixture (request) that lets you skip the UI entirely. The waitForResponse primitive you met in Lesson 2 is the bridge.