iframes (payment forms, embedded editors, widgets) and Shadow DOM (web components) are where Selenium tests get complicated. Playwright makes both much simpler: frameLocator() scopes locators into a frame without switching context, and locators pierce open shadow roots automatically.

iframes & Shadow DOM

Time: ~20 minutes. You will type into an element inside an iframe (which normal locators can’t reach), then see how Playwright pierces Shadow DOM automatically. Uses the public practice site the-internet.herokuapp.com.

Goal

A passing test that types into a rich-text editor living inside an <iframe>, plus understanding of why frameLocator is required.

Step 1 — See the problem first (why iframes are special)

Create tests/frames.spec.ts:

import { test, expect } from '@playwright/test';test('WRONG way: locating iframe content directly fails', async ({ page }) => {  await page.goto('https://the-internet.herokuapp.com/iframe');  // This targets the main page, but the editor is INSIDE an iframe:  const body = page.locator('#tinymce');  // It will time out because #tinymce lives in a separate document.  await expect(body).toBeVisible({ timeout: 3000 }).catch(() => {    console.log('As expected: cannot see iframe content from the main page.');  });});
npx playwright test frames -g "WRONG way"

✅ The console note prints — locating iframe content from the main page doesn’t work. Now let’s do it correctly.

Step 2 — The RIGHT way: frameLocator

Add this test:

test('type into an editor inside an iframe', async ({ page }) => {  await page.goto('https://the-internet.herokuapp.com/iframe');  // Scope INTO the iframe, then locate normally inside it:  const frame = page.frameLocator('#mce_0_ifr');  const editor = frame.locator('#tinymce');  await editor.click();  await editor.fill('Hello from inside the iframe!');  await expect(editor).toHaveText('Hello from inside the iframe!');});

Step 3 — Run it

npx playwright test frames -g "type into an editor"
✅ 1 passed. The key line is page.frameLocator('#mce_0_ifr') — it scopes into the iframe, and everything chained after it runs inside that frame.

Step 4 — Watch it happen

npx playwright test frames -g "type into an editor" --headed
✅ You see the text appear inside the editor. Notice you didn’t need any special “switch to frame / switch back” calls (unlike older tools) — frameLocator handles scoping cleanly.

Step 5 — Remember the common gotcha

Results often appear on the parent page, not inside the frame. For example, a payment iframe collects the card, but the “Payment successful” message renders on the merchant page. So: - Fill fields inside the frame (frame.locator(...)). - Assert outcomes where they actually appear (often page.getByText(...) on the parent).

Step 6 — Nested iframes (pattern to know)

If a frame is inside another frame, chain frameLocator:

const inner = page.frameLocator('#outer').frameLocator('#inner');await inner.getByRole('button', { name: 'Submit' }).click();
Each frameLocator steps one level deeper.

Step 7 — Shadow DOM (the good news)

Modern web components hide their internals in a “shadow root.” With many tools you must manually traverse it — but Playwright’s semantic locators pierce open shadow DOM automatically. So for a web component you’d simply write:

// No special API needed — this reaches inside an open shadow root:await page.getByRole('button', { name: 'Submit' }).click();await page.getByLabel('Email').fill('naveed@example.com');
That’s it — getByRole/getByLabel/getByTestId see through open shadow roots. (Closed shadow roots are intentionally inaccessible by design.)

What you just learned

  • Why iframe content needs frameLocator (separate document).
  • How to type into and assert iframe content.
  • The gotcha: assert results where they render (often the parent page).
  • Playwright pierces open Shadow DOM automatically — no special handling.

Next

Walkthrough 13 handles file upload and download with real content checks.

Advertisement

Shadow DOM

Level: L4.

What is it?

The Shadow DOM is an encapsulated DOM subtree inside web components (custom elements). Its internals are hidden from normal CSS/DOM queries.

Why do we need it?

Design systems and widgets (e.g., many <custom-element>s) use shadow roots. Standard CSS selectors can’t “pierce” them — but Playwright’s engines can.

How does it work?

Playwright’s getByRole/getByText/getByTestId and CSS locators pierce open shadow roots automatically. You usually don’t need anything special. (Closed shadow roots and deep CSS combinators like >>> are the exceptions.)

Syntax

// These pierce open shadow DOM automatically:await page.getByRole('button', { name: 'Submit' }).click();await page.getByText('Inside shadow').click();await page.getByTestId('shadow-input').fill('hi');

Basic Example

await page.locator('my-widget').getByRole('textbox').fill('value');

Practical Example — web component form

test('fill a shadow-DOM web component', async ({ page }) => {  await page.goto('/component-demo');  // getByLabel/getByRole reach into the component's shadow root  await page.getByLabel('Email').fill('naveed@example.com');  await page.getByRole('button', { name: 'Subscribe' }).click();  await expect(page.getByText('Subscribed!')).toBeVisible();});

Line-by-Line Explanation

  • The email input lives inside a component’s shadow root, but getByLabel locates it because Playwright pierces open shadow DOM.
  • No special API is needed — semantic locators just work, which is a big advantage over tools that require manual shadow traversal.

Common Mistakes

  • Assuming you must manually traverse shadowRoot (usually unnecessary in Playwright).
  • Using raw document.querySelector via evaluate (won’t pierce; and loses auto-waiting).
  • Expecting closed shadow roots to be reachable (they aren’t — a design constraint).

Best Practices

  • Use normal semantic locators; let Playwright pierce open shadow DOM.
  • Avoid CSS that assumes a flat DOM across component boundaries.
  • If a widget uses closed shadow roots, coordinate with devs for testability hooks.

Interview Questions

  • Q: How does Playwright handle Shadow DOM? A: Its locator engines pierce open shadow roots automatically.
  • Q: Open vs closed shadow root? A: Open is accessible to scripts/Playwright; closed is not exposed and can’t be pierced.
  • Q: Why prefer getByRole for web components? A: It reaches into shadow DOM and stays stable across component internals.

Practice Exercise

Find a web-component demo (open shadow DOM) and interact with an input and button inside it using only getByRole/getByLabel; assert the result.

Real-World Scenario

A team on a Lit-based design system worried Shadow DOM would block automation. In practice, Playwright’s semantic locators reached component internals with no special handling — testing the design system was as simple as testing plain HTML.

iFrames (Advanced)

Level: L4. Builds on Module 17.

What is it?

Advanced iframe handling: cross-origin frames, nested frames, frames that load late, and combining frames with network/dialog handling.

Why do we need it?

Real embeds (payment, chat widgets, third-party editors) are cross-origin, load asynchronously, and nest. You need robust patterns beyond the basics.

How does it work?

frameLocator auto-waits for the frame and its content, works across origins, and chains for nesting. Actions inside behave like normal locators (auto-wait, strict mode).

Syntax

const pay = page.frameLocator('iframe[title="Secure payment"]');await pay.getByLabel('Card number').fill('4111111111111111');// nestedconst inner = page.frameLocator('#outer').frameLocator('#inner');

Basic Example

await page.frameLocator('#checkout').getByRole('button', { name: 'Pay' }).click();

Practical Example — late-loading cross-origin payment frame

test('pay via embedded gateway', async ({ page }) => {  await page.goto('/checkout');  const frame = page.frameLocator('iframe[name="gateway"]');  // frameLocator auto-waits for the (late-loading, cross-origin) frame  await frame.getByLabel('Card number').fill('4111111111111111');  await frame.getByLabel('Expiry').fill('12/30');  await frame.getByLabel('CVC').fill('123');  await frame.getByRole('button', { name: 'Pay' }).click();  // success message is on the PARENT page, not the frame:  await expect(page.getByText('Payment successful')).toBeVisible();});

Line-by-Line Explanation

  • frameLocator('iframe[name="gateway"]') targets the cross-origin gateway; it waits for the frame to attach and render.
  • Card fields are filled inside the frame.
  • The success banner is asserted on the parent page — a common gotcha: results often live outside the frame.

Common Mistakes

  • Asserting success inside the frame when it renders on the parent.
  • Assuming cross-origin frames need special APIs (frameLocator handles them).
  • Not waiting for late frames (frameLocator does, but a wrong selector won’t match).

Best Practices

  • Wrap frame access in the relevant Page Object.
  • Know where each result renders (frame vs parent).
  • Use precise, stable frame selectors (name/title/id).

Interview Questions

  • Q: Cross-origin iframe handling? A: frameLocator works across origins; no special API needed.
  • Q: Common iframe assertion mistake? A: Asserting outcomes inside the frame when they appear on the parent page.
  • Q: Late-loading frames? A: frameLocator auto-waits; ensure the selector matches once loaded.

Practice Exercise

Automate a cross-origin embedded form (e.g., a demo payment/chat widget): fill fields in the frame, trigger submit, and assert the outcome on the correct (frame or parent) document.

Real-World Scenario

A Stripe-based checkout kept “failing” because the test asserted the confirmation inside the Stripe iframe, where it never appears — the confirmation renders on the merchant page. Fixing the assertion target made the suite green and correct.

FAQs

Does Playwright need switchTo().frame() like Selenium?

No. Use page.frameLocator(selector) and chain locators from it; actions and assertions inside the frame auto-wait like any other locator.

Can Playwright find elements inside Shadow DOM?

Yes, for open shadow roots: CSS and getBy* locators pierce them automatically. XPath does not pierce shadow roots, and closed shadow roots are not accessible.