Playwright architecture, selector reliability, and advanced interaction patterns.

Automating Elements Inside Nested Iframes

Checkout flows, embedded dashboards, CMS preview panes and vendor widgets all produce the same shape: an <iframe> inside an <iframe>, sometimes three deep, with the element you actually need to click sitting in the innermost document. A locator built against the top-level page will never see that element, because each iframe is a separate browsing context with its own document, its own execution context and its own layout tree. Playwright handles this with frameLocator() and locator.contentFrame(), which let you express one hop per boundary and keep the whole chain lazy so it re-resolves on every retry. This page shows how to build those chains, how to disambiguate when several iframes match the same selector, and how to keep the chain alive when the host application remounts a frame mid-test.

Document boundaries in a two-level iframe nest Three separate documents chained left to right, each entered by one frame locator hop, with the target field in the innermost document. main document page object hop one iframe: checkout-shell same-origin hop two iframe: card-form cross-origin card number field flat CSS cannot cross a boundary
Each iframe is a separate document, so reaching the innermost field costs exactly one frame locator hop per boundary.

Root cause: a selector is scoped to one document

A CSS or XPath selector is evaluated against a single document tree, and an <iframe> element is a leaf in that tree — its content document is a different tree hanging off the element, not a descendant of it. That is why page.locator('#checkout-shell input') matches nothing no matter how long it waits: the input genuinely does not exist in the document the locator is querying. Playwright does not paper over this with a piercing combinator; it asks you to name each boundary explicitly, which keeps the chain readable and makes the failure point obvious when a hop breaks. This page sits under Iframes & Embedded Content in Reliable Selector Strategies for Playwright.

The same rule explains a second common surprise: keyboard and mouse coordinates, scroll position and viewport clipping are all resolved per document. An element can be perfectly visible inside its own frame while the frame element itself is scrolled out of the parent's viewport, so Playwright reports the target as not visible even though a screenshot of the inner document would show it. Every hop you write restores one layer of that context.

The second half of the root cause is timing. Nested frames load in sequence — the outer document must parse before the inner <iframe> element exists, and that inner frame then performs its own navigation before its body has any content. A test that grabs a Frame object early gets a snapshot of a browsing context that may be replaced seconds later. FrameLocator avoids the problem by design: it stores selectors, not resolved frames, and walks the entire chain from the page down at the moment an action or assertion runs.

Minimal reproducible example

The test below fills a card number that lives two frames deep. Every line that does real work is commented, including the two anti-patterns at the bottom that produce the errors most teams hit first.

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

test('fills a field two iframes deep', async ({ page }) => {
  await page.goto('/checkout');

  // Hop 1: enter the merchant's embedded checkout shell (same origin).
  const shell = page.frameLocator('iframe#checkout-shell');

  // Hop 2: enter the payment provider's frame nested inside the shell.
  // Chaining frameLocator() is how depth is expressed — one call per boundary.
  const cardFrame = shell.frameLocator('iframe[title="Secure card entry"]');

  // Inside the innermost document, ordinary locators work normally.
  const cardNumber = cardFrame.getByRole('textbox', { name: 'Card number' });

  // Nothing has been resolved yet. The whole chain is walked here, and
  // re-walked on every auto-wait retry until the field is actionable.
  await cardNumber.fill('4242424242424242');
  await expect(cardNumber).toHaveValue('4242 4242 4242 4242');

  // Anti-pattern A: one flat selector across the boundary. The input is not
  // a descendant of the iframe element, so this times out with
  // "Timeout 30000ms exceeded" and an empty call log.
  // await page.locator('iframe#checkout-shell input[name="cardnumber"]').fill('4242');

  // Anti-pattern B: caching a Frame object. If the shell remounts, later
  // calls throw "Frame was detached" instead of re-resolving.
  // const frame = page.frames().find((f) => f.url().includes('/shell'));
});

The comment on fill() is the important one. page.frameLocator() performs no lookup when you call it — it builds a description. Resolution of hop one, hop two and the final getByRole() all happen inside the action, under one action timeout, and the whole walk repeats on each retry. That is why a chain survives a frame that is still loading, and why splitting the chain into eagerly-resolved handles reintroduces flakiness.

When a frame locator chain is resolved A sequence showing the test worker walking from the root frame through the outer frame to the inner frame at action time, then repeating the walk on retry. test worker root frame outer frame inner frame action starts, chain walk begins match iframe element, enter it match nested iframe, enter it element found and actionable on failure the entire walk repeats until the action timeout
Resolution is deferred to action time, so a chain tolerates frames that attach or navigate after the locator was declared.

Step-by-step fix

  1. Write one hop per boundary, top down. Start at page, call frameLocator() for the outermost iframe, then chain a further frameLocator() for each nested one. Give each hop a selector that identifies the frame itself — #id, [name=...], [title=...] or [src*=...] — rather than something inside it. Never try to collapse two boundaries into one selector.
  2. Disambiguate duplicates with contentFrame(). If a page renders several structurally identical iframes, page.frameLocator('iframe') fails with strict mode violation: locator('iframe') resolved to 3 elements. Build a Locator first, narrow it with .nth(), .first() or .filter(), then call .contentFrame() on it to descend. The same narrowing vocabulary is covered in Scoping Locators with filter() and has, and the diagnosis pattern in Resolving Strict Mode Violations.
  3. Query the final element by role or label, not by structure. Once you are inside the innermost document, use getByRole(), getByLabel() or getByPlaceholder(). Third-party frames change their internal class names without warning but keep their accessible names stable, which is the argument made in full under getByRole & Accessibility Selectors.
  4. Assert the frame is present before acting when it loads lazily. For an <iframe loading="lazy"> or one mounted behind a tab, call frameLocator.owner() to get the <iframe> element back as a Locator, then await expect(shell.owner()).toBeVisible() or await shell.owner().scrollIntoViewIfNeeded(). The frame only attaches once the element enters the viewport.
  5. Never hold a Frame object across a re-render. page.frames() and page.frame({ url }) return snapshots of live browsing contexts. When the host app swaps the iframe, every later call on that snapshot throws Frame was detached or Execution context was destroyed, most likely because of a navigation. Keep the FrameLocator chain as a value and let it re-resolve.
  6. Store the chain, not the result. Declare the chain once — in a page object or a fixture — and reuse the locator. Because nothing is resolved until an action runs, the same constant works before and after a remount, which pairs well with the structure described in Browser Contexts & Isolation.
  7. Budget one timeout for the whole walk. A three-hop chain still runs under a single action timeout, so a slow outer frame eats the budget for the inner one. Raise actionTimeout for the specific step, or wait on the outer frame separately first; the trade-offs are set out in Configuring Retries and Timeouts for Stable CI.
Choosing a frame entry API A decision tree branching on how many iframes match, leading to frameLocator, contentFrame or the Frame object, all followed by chaining for extra depth. how many iframes match? exactly one page.frameLocator() several look alike filter, contentFrame need frame metadata page.frame by url chain another hop for each extra level every hop re-resolves on retry, so declare chains once and reuse them
Pick the entry API by how many iframes match and whether you need frame metadata; depth is always added by chaining another hop.

Two conventions make deep chains maintainable once they leave a single spec file. Expose each boundary as a named accessor rather than a public property, so a page object returns get cardFrame() built from get shellFrame(); the getters run fresh on every access and cannot leak a stale reference. And select frames on attributes the vendor treats as public API — name, title, or a path fragment in src — because those appear in the provider's own integration docs and change on a release cadence you can follow, whereas generated ids and DOM position change on every deploy.

Troubleshooting variants

Several identical iframes on the page

Ad slots, repeated video embeds and multi-tenant widget grids all render the same markup several times, so a frame selector that worked in staging fails in production with strict mode violation. Rather than reaching for a brittle positional CSS selector, build the element locator first and narrow it on something meaningful — the accessible name of the surrounding card, the title attribute, or a substring of src:

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

test('acts inside the second of several identical embeds', async ({ page }) => {
  await page.goto('/dashboard');

  // Narrow on the *element* before descending, so strict mode is satisfied.
  const panel = page.getByRole('region', { name: 'Revenue' });
  const embed = panel.locator('iframe[title="Report viewer"]');

  // contentFrame() turns a resolved-at-action-time Locator into a FrameLocator.
  const report = embed.contentFrame();

  // A further hop: the report viewer itself embeds a chart sandbox.
  const chart = report.frameLocator('iframe[name="chart-sandbox"]');

  await expect(chart.getByRole('heading', { name: 'Q3 revenue' })).toBeVisible();
  await chart.getByRole('button', { name: 'Export CSV' }).click();
});

Note that contentFrame() and frameLocator() mix freely in one chain: the first hop above narrows an ambiguous element locator, the second uses a unique selector directly. Both produce the same kind of value, and neither performs a lookup until the final click() runs, so the strictness guarantee applies per hop at that moment rather than when the chain was constructed.

The chain times out but the frame is clearly on screen

Read the call log printed under Timeout 30000ms exceeded. — it names the last hop that resolved, which tells you exactly which boundary failed. Three causes account for most of these. The frame element matched but has display: none or zero height, so everything inside is reported as not visible; assert on owner() first to separate "frame missing" from "frame hidden". The frame exists but is still at about:blank because the application creates it with JavaScript and writes content afterwards; wait for a stable element inside rather than for load state. Or the inner document renders after its own XHR settles, in which case wait on the element rather than the network, as argued in Waiting for Network Idle vs Element State.

The frame remounts and the action fails mid-test

Single-page applications frequently destroy and recreate an iframe when a tab changes or a token refreshes. If you cached a Frame, you get Frame was detached; if you cached an ElementHandle from inside the frame, you get Element is not attached to the DOM. Both are the same mistake: holding a resolved reference across a lifecycle event. A FrameLocator chain has no such problem because it re-walks from page on every retry, so the second attempt simply finds the new frame. If the remount is triggered by a network response you control, stabilise it by stubbing that response — see Mocking API Responses with Playwright.

Verification

Prove the traversal in three independent ways. First, assert on state inside the innermost document after acting — await expect(cardNumber).toHaveValue(...) can only pass if every hop resolved, because a broken hop throws long before the assertion runs. Second, open the recording in the Playwright Trace Viewer: each action records its full locator string, including every contentFrame() segment, and the DOM snapshot lets you click into the nested document to confirm you landed in the frame you meant. Third, run a cheap structural check at the start of the spec — await expect(page.locator('iframe')).toHaveCount(2) on the top document, plus a visibility assertion on shell.owner() — so a vendor silently adding or removing an embed fails with a clear message instead of a mysterious timeout weeks later. If the innermost frame is third-party and refuses to expose stable names, the constraints and workarounds are catalogued in Handling Cross-Origin Iframe Restrictions.

Frequently Asked Questions

How deep can a frame locator chain go?

There is no fixed limit — you add one hop per boundary and Playwright walks them in order at action time. In practice three levels is the deepest real-world nest most teams meet (host page, vendor shell, provider form). Depth costs you clarity rather than capability, so name each intermediate chain in a variable or page object method instead of writing one long expression.

What is the difference between frameLocator() and contentFrame()?

page.frameLocator(selector) takes a selector and enters the matching frame in one call, and it enforces strict mode on that selector. locator.contentFrame() starts from a Locator you have already narrowed with filter(), nth() or a scoped ancestor, and converts it into a frame you can query. Use the first when the selector is unique, the second when it is not. frameLocator.owner() is the inverse: it hands back the <iframe> element as a Locator.

Does a cross-origin nested iframe need anything special?

No. Playwright attaches to each frame directly rather than relying on same-origin JavaScript, so locator chains, clicks and assertions work identically whether the nested document shares the parent's origin or not. The limits appear elsewhere: you cannot reach across origins from inside page.evaluate(), and some providers set headers or sandbox flags that change how the frame behaves under automation.

Why does my test pass locally and time out in CI?

Nested frames multiply network round trips, and each one has to finish before the next boundary exists. On a cold CI runner the outer frame can consume most of the action timeout before the inner frame has even attached, producing a timeout whose call log stops at hop one. Raise the timeout for that specific step, warm the vendor origin, or stub the third-party response so the depth of the nest no longer depends on external latency.

Back to overview