How to Handle iframes in Playwright, Cypress and Selenium

10 min readupdated July 24, 2026

You are testing a checkout page. The card number field is right there on the screen. Your test cannot find it:

Unable to locate element: input[name="cardnumber"]

You check the selector three times. It is correct. You can see the field. The test says it does not exist.

The field is inside an iframe, and your test is looking in the wrong document.

What an iframe actually is

An iframe is a whole separate page embedded inside your page. Not a section, not a container. A different document, with its own DOM, its own scripts and often its own origin.

That word "separate" is the important one. When your test asks the page to find input[name="cardnumber"], it searches the outer document. The field lives in the inner one. It genuinely is not there, so the tool is telling you the truth.

You meet iframes in predictable places: payment forms like Stripe, embedded videos, chat widgets, CAPTCHAs, adverts, some rich text editors, and embedded dashboards or maps.

First, decide whether you should test inside it at all

This question comes before any code, and skipping it is how teams end up maintaining tests for software they do not own.

If the iframe belongs to a third party, you are usually not testing their code. You are testing that you embedded it correctly. Stripe tests Stripe's card form far more thoroughly than you ever will. A test that fills in their fields breaks when they redesign, which they will, and that failure tells you nothing about your product.

So for third-party widgets, prefer checking your side of the contract:

// enough for most cases: the widget is present and configured correctly
await expect(page.locator('iframe[title="Secure payment input"]')).toBeVisible();

Then cover the actual payment flow with a test that uses the provider's test mode through the API, or with one end-to-end test rather than twenty.

Go inside the iframe when: the content is yours, or the whole user journey genuinely runs through it and you have no other way to cover it. A payment form is often that case. A YouTube player almost never is.

Playwright

The cleanest of the three, because there is no switching.

const frame = page.frameLocator('iframe[title="Secure payment input"]');
await frame.getByLabel('Card number').fill('4242424242424242');
await frame.getByLabel('Expiry').fill('12/30');

frameLocator returns something you chain off, exactly like a normal locator. It scopes the search into that document and nothing more. There is no state to restore, so you cannot forget to come back, which removes an entire category of bug that Selenium users know well.

Everything else works normally inside it, including auto-waiting and web-first assertions:

await expect(frame.getByText('Card accepted')).toBeVisible();

Nested iframes just chain:

page.frameLocator('#outer').frameLocator('#inner').getByRole('button');

If you need the frame as an object rather than a locator, page.frame({ url: /stripe/ }) gives you one, which is occasionally useful for waiting on frame navigation.

Selenium

Selenium switches the driver's context, and this is where most iframe bugs come from.

// by element, the most reliable option
WebElement frame = driver.findElement(By.cssSelector("iframe[title='Secure payment input']"));
driver.switchTo().frame(frame);

driver.findElement(By.name("cardnumber")).sendKeys("4242424242424242");

// go back to the main document
driver.switchTo().defaultContent();
// JavaScript
const frame = await driver.findElement(By.css("iframe[title='Secure payment input']"));
await driver.switchTo().frame(frame);
await driver.findElement(By.name('cardnumber')).sendKeys('4242424242424242');
await driver.switchTo().defaultContent();

You can also switch by name, id or index. Avoid the index. switchTo().frame(0) means "whichever iframe happens to be first", and adverts or chat widgets change that order without warning.

The mistake everyone makes once: forgetting to switch back.

After switchTo().frame(), every command goes to the inner document until you say otherwise. So this fails:

driver.switchTo().frame(frame);
driver.findElement(By.name("cardnumber")).sendKeys("4242...");
driver.findElement(By.id("place-order")).click();  // NoSuchElementException

The Place order button is in the outer page. You are still inside the iframe. The error says the element does not exist, which is true and completely unhelpful if you have forgotten where the driver is pointing.

Whenever a Selenium test reports "no such element" for something you can see on screen, ask which document the driver is in. That question resolves this class of failure faster than any amount of selector tuning.

Two more things worth knowing. switchTo().parentFrame() moves up one level, which matters with nested frames, while defaultContent() goes all the way to the top. And a switch is invalidated when the page reloads, so switch after navigation, not before.

The safest habit is to wrap it so you cannot forget:

async function inFrame(driver, selector, fn) {
  const frame = await driver.findElement(By.css(selector));
  await driver.switchTo().frame(frame);
  try {
    return await fn();
  } finally {
    await driver.switchTo().defaultContent();
  }
}

The finally block is the whole point: the context is restored even when the test fails inside the frame.

Cypress

Cypress is honest about this being a weak spot. There is no switch command, and one has been an open proposal for years. Same-origin iframes work with a documented pattern:

cy.get('iframe')
  .its('0.contentDocument.body')
  .should('not.be.empty')
  .then(cy.wrap)
  .find('[data-testid="submit"]')
  .click();

Each part earns its place, so it is worth reading slowly rather than copying:

  • .its('0.contentDocument.body') reaches into the iframe's document and takes its body.
  • .should('not.be.empty') waits for it. This is the part people delete and then get flaky tests. An iframe loads its document asynchronously, so without this you can grab the body before anything has rendered into it.
  • .then(cy.wrap) matters more than it looks. The previous step gives you a raw DOM element, which has no retrying. Wrapping turns it back into a Cypress subject, so the commands after it retry normally again.

Since you will use this more than once, put it in a custom command:

Cypress.Commands.add('iframe', (selector) => {
  return cy.get(selector)
    .its('0.contentDocument.body')
    .should('not.be.empty')
    .then(cy.wrap);
});

// then
cy.iframe('#payment').find('[data-testid="card-number"]').type('4242424242424242');

The real limitation is cross-origin. This pattern only works for iframes from your own origin. A Stripe or PayPal iframe comes from theirs, and the browser's security rules stop your test reading into it. That is the browser doing its job, not a Cypress bug, and it follows directly from Cypress running inside the page.

If your critical flow runs through a third-party iframe, this is a genuine reason to consider a different tool for that flow. It is worth knowing before you build a suite around it rather than after.

The debugging checklist

When an element cannot be found and you can see it on screen:

  1. Look for an iframe. Open developer tools, inspect the element, and check whether an <iframe> sits above it in the tree.
  2. In Selenium, ask where the driver is. A forgotten switch is more common than a wrong selector.
  3. Check whether the iframe has loaded. The element can be missing simply because the inner document is still empty.
  4. Check the origin. If the iframe's src points at another domain, that decides what your tool can do.
  5. Do not select the iframe by index unless you control every frame on the page.

Remember this

An iframe is a separate document, so "element not found" is accurate rather than broken. Playwright scopes into one with frameLocator and nothing to undo. Selenium switches context, and you must switch back, ideally in a finally block. Cypress reaches in for same-origin frames only, and needs the not.be.empty wait and the cy.wrap to keep retrying.

Before any of that, ask whether the content is yours. Testing a third party's form through their iframe usually costs maintenance and proves little.

Related: waiting compared across all three tools and Cypress vs Selenium.

References: Playwright frames and Cypress iframe guidance.

Put it into practice

Solve a graded problem: write a test, and we check it would catch a real bug.

Browse the problems →

Keep reading