level 03 / frames-and-shadow-dom

Frames & Shadow DOM

Locating elements inside iframes and shadow roots — the two DOM boundaries that break naive selectors.

practice site: The Internet — Frames

Two boundaries, two behaviours

An iframe embeds a separate document with its own DOM tree. A shadow root encapsulates a subtree inside the same document. Both hide content from plain page.locator() — but Playwright handles them very differently: frames need frameLocator(), shadow DOM pierces automatically.

iframes: frameLocator

// The iframe on the page
// <iframe src="editor.html" title="Rich text editor"></iframe>

const editor = page.frameLocator('iframe[title="Rich text editor"]');

// Locate INSIDE the frame with the same locator API
await editor.getByRole('textbox').fill('Hello from inside the frame');
await editor.getByRole('button', { name: 'Bold' }).click();

// Assertions work the same way
await expect(editor.locator('#tinymce')).toContainText('Hello');

frameLocator() is lazy and auto-waiting like every locator — it re-resolves the frame on every action, so a frame that reloads doesn’t break your test.

Nested frames

// Frame inside a frame — chain frameLocator calls
const inner = page
  .frameLocator('#outer-frame')
  .frameLocator('#inner-frame');

await inner.getByText('Deeply nested content').click();

frame() vs frameLocator()

// frameLocator — for locating elements (preferred, auto-waits)
const fl = page.frameLocator('iframe[name="checkout"]');

// frame() — a Frame object, for frame-level operations
const frame = page.frame({ name: 'checkout' });
await frame?.evaluate(() => document.title);   // run JS in the frame
const url = frame?.url();                       // read frame URL
Key insight: Reach for frameLocator() by default. Use frame() only when you need the Frame object itself — evaluating scripts, reading the frame URL, or waiting for frame navigation.

Shadow DOM: it just works

Playwright locators pierce open shadow roots automatically:

// <custom-dropdown>
//   #shadow-root (open)
//     <button class="trigger">Choose...</button>
// </custom-dropdown>

// No special API — this finds the button inside the shadow root
await page.locator('custom-dropdown .trigger').click();
await page.getByRole('button', { name: 'Choose...' }).click(); // also works

Exceptions:

  • Closed shadow roots are not reachable — by design, by anyone.
  • XPath does not pierce shadow DOM. CSS and the getBy* locators do.
// ❌ XPath stops at the shadow boundary
await page.locator('//custom-dropdown//button').click(); // fails

// ✅ CSS pierces
await page.locator('custom-dropdown button').click();

Web components in practice

test('add book in shadow-DOM PWA', async ({ page }) => {
  await page.goto('https://books-pwakit.appspot.com/');

  // book-app → app-header → input live in nested shadow roots.
  // Plain CSS walks through all of them:
  await page.locator('book-app input#input').fill('typescript');
  await page.keyboard.press('Enter');

  await expect(page.locator('book-item').first()).toBeVisible();
});

Reference

page.frameLocator(sel)
Locator scope inside an iframe (auto-waits)
fl.frameLocator(sel)
Chain into nested frames
page.frame({ name })
Frame object by name attribute
page.frames()
All frames including main frame
frame.evaluate(fn)
Run JavaScript inside the frame
page.locator('host element css')
CSS pierces open shadow roots automatically

Practice on The Internet

  1. https://the-internet.herokuapp.com/iframe — type into the TinyMCE editor via frameLocator.
  2. https://the-internet.herokuapp.com/nested_frames — read text from the bottom frame via chained frame access.
  3. https://books-pwakit.appspot.com/ — search for a book through three levels of shadow DOM.

Interview questions