How to fix Playwright "No frame for selector" and iframe locator issues?

Playwright

"No frame for selector" and similar iframe-related errors happen when test code tries to locate elements inside an iframe using the top-level page locator, which only searches the main document. Iframes create separate browsing contexts with their own DOM trees — a page.locator('input[name="card"]') call will never find an element that lives inside a Stripe, PayPal, or custom embedded frame. Using the correct frameLocator API is the primary fix for working with Playwright iframe interactions.

Common mistake

test('fills card details', async ({ page }) => {
  await page.goto('https://shop.example.com/checkout');

  // This fails — the input is inside an iframe, not the main document
  await page.getByLabel('Card number').fill('4242 4242 4242 4242');
});

Also common: using page.frame() with a name that doesn't match, or trying to query elements before the iframe has finished loading.

The fix

Use page.frameLocator() to scope queries to the iframe's document:

import { test, expect } from '@playwright/test';

test('completes payment form', async ({ page }) => {
  await page.goto('https://shop.example.com/checkout');

  // Wait for the iframe to be present in the main DOM
  await expect(page.locator('iframe[title="Secure payment form"]')).toBeVisible();

  // frameLocator scopes all subsequent queries to the iframe's document
  const paymentFrame = page.frameLocator('iframe[title="Secure payment form"]');

  await paymentFrame.getByLabel('Card number').fill('4242 4242 4242 4242');
  await paymentFrame.getByLabel('Expiry date').fill('12/28');
  await paymentFrame.getByLabel('CVC').fill('123');

  // Submit button is back in the main document
  await page.getByRole('button', { name: 'Pay now' }).click();

  await expect(page.getByText('Payment successful')).toBeVisible();
});

For nested iframes (an iframe inside an iframe):

const outerFrame = page.frameLocator('#checkout-container');
const innerFrame = outerFrame.frameLocator('iframe[title="Card details"]');

await innerFrame.getByLabel('Card number').fill('4242 4242 4242 4242');

For dynamically named or title-less iframes, use a more flexible selector:

// Match by partial title or any stable attribute
const frame = page.frameLocator('iframe[src*="stripe.com/elements"]');
await frame.getByPlaceholder('1234 1234 1234 1234').fill('4242 4242 4242 4242');

Why it works

Iframes in a browser are independent browsing contexts — they have their own separate document, DOM, and JavaScript execution environment. Playwright's frameLocator() returns a FrameLocator object that re-targets all locator queries to the content document of the matched iframe element. Unlike page.frame(), which returns a Frame reference that can become stale if the iframe is remounted, frameLocator() lazily resolves on each action — meaning if the application remounts the iframe after a user interaction, subsequent actions still find the correct frame.

Tips

  • Identify the iframe by title, name, or a stable src pattern — these attributes are less likely to change than CSS classes.
  • If the iframe is from a third-party origin (Stripe, Recaptcha, PayPal), Playwright can interact with its UI but cannot inject scripts or intercept requests from within the cross-origin context.
  • For Stripe Elements specifically, each input field is a separate iframe — you'll need a frameLocator per field unless you're using Stripe's single Payment Element.
  • If you need to work with the Frame object directly (to call frame.evaluate() or frame.waitForURL()), use page.frame({ url: /stripe/ }) after confirming the frame is loaded.