Working with Iframes and Frames

8 min read

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:

  • frameLocator is lazy — it doesn't query the iframe until you await an 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 waitFor for 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

Iframe testing in Playwright
  • – 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 document

For 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 the cypress-iframe plugin) → page.frameLocator(...) (built-in)
  • cy.origin('stripe.com', () => { ... }) → just page.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.locator instead of page.frameLocator for iframe content. page.locator('input[name=cardNumber]') searches the parent document — the parent has no cardNumber input, so the locator times out with "no element matched." Always start with page.frameLocator(...) to descend, then chain locators inside.
  • Caching a Frame reference and reusing it after a page reload. If you do const f = page.frame({ name: 'x' }) and then page.reload(), f is stale. Use frameLocator instead — 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.

  1. Use https://the-internet.herokuapp.com (public Playwright/Selenium sandbox with iframe scenarios). Add baseURL: "https://the-internet.herokuapp.com" or hardcode in goto.

  2. 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");
      });
    });
  3. Run all three tests across all three browsers.

  4. Demonstrate the parent-vs-iframe locator bug. In the first test, change editor.locator("body#tinymce") to page.locator("body#tinymce"). Run again. The test times out — page.locator doesn't enter the iframe. This is the single biggest iframe-testing pitfall; once you've felt it, you won't make it again.

  5. 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.

// tip to track lessons you complete and pick up where you left off across devices.