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.