# Iframes and Shadow DOM

> **When to use**: When your application embeds content in `<iframe>` elements (payment widgets, third-party embeds, legacy modules) or uses Web Components with Shadow DOM (design systems, custom elements, Salesforce Lightning).
> **Prerequisites**: [core/locators.md](locators.md), [core/assertions-and-waiting.md](assertions-and-waiting.md)

## Quick Reference

```typescript
// Iframes — use frameLocator to reach inside
const frame = page.frameLocator('iframe[title="Payment"]');
await frame.getByLabel('Card number').fill('4242424242424242');

// Nested iframes — chain frameLocator calls
const inner = page.frameLocator('#outer').frameLocator('#inner');
await inner.getByRole('button', { name: 'Submit' }).click();

// Shadow DOM — Playwright pierces open shadow roots automatically
await page.getByRole('button', { name: 'Toggle' }).click();       // auto-pierces
await page.locator('my-component').getByText('Hello').click();     // auto-pierces
```

## Patterns

### Basic iframe Interaction with `frameLocator()`

**Use when**: You need to interact with content inside an `<iframe>` -- payment forms, embedded editors, captchas, third-party widgets.
**Avoid when**: The content is in the main frame. Never use `frameLocator` for Shadow DOM.

`frameLocator()` returns a locator-like object scoped to the iframe's document. All standard locator methods work inside it.

**TypeScript**
```typescript
import { test, expect } from '@playwright/test';

test('complete payment inside Stripe iframe', async ({ page }) => {
  await page.goto('/checkout');

  // Locate the iframe by its title, name, or a CSS selector
  const paymentFrame = page.frameLocator('iframe[title="Secure payment"]');

  // Use normal locators inside the frame
  await paymentFrame.getByLabel('Card number').fill('4242424242424242');
  await paymentFrame.getByLabel('Expiry').fill('12/28');
  await paymentFrame.getByLabel('CVC').fill('123');
  await paymentFrame.getByRole('button', { name: 'Pay' }).click();

  // Assertion on content inside the iframe
  await expect(paymentFrame.getByText('Payment successful')).toBeVisible();

  // Assertion on the parent page (outside the iframe)
  await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();
});
```

**JavaScript**
```javascript
const { test, expect } = require('@playwright/test');

test('complete payment inside Stripe iframe', async ({ page }) => {
  await page.goto('/checkout');

  const paymentFrame = page.frameLocator('iframe[title="Secure payment"]');
  await paymentFrame.getByLabel('Card number').fill('4242424242424242');
  await paymentFrame.getByLabel('Expiry').fill('12/28');
  await paymentFrame.getByLabel('CVC').fill('123');
  await paymentFrame.getByRole('button', { name: 'Pay' }).click();

  await expect(paymentFrame.getByText('Payment successful')).toBeVisible();
  await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();
});
```

### Selecting the Right iframe

**Use when**: Multiple iframes exist on the page or the iframe has no obvious identifier.
**Avoid when**: There is only one iframe and a simple `page.frameLocator('iframe')` works.

**TypeScript**
```typescript
import { test, expect } from '@playwright/test';

test('interact with the correct iframe among many', async ({ page }) => {
  await page.goto('/dashboard');

  // By title attribute (best — accessible and stable)
  const chatFrame = page.frameLocator('iframe[title="Live chat"]');

  // By name attribute
  const reportFrame = page.frameLocator('iframe[name="analytics-report"]');

  // By src URL pattern
  const adFrame = page.frameLocator('iframe[src*="ads.example.com"]');

  // By index — when nothing else works (0-indexed)
  const thirdFrame = page.frameLocator('iframe').nth(2);

  // By parent container — scope to a section first
  const sidebar = page.getByRole('complementary');
  const sidebarFrame = sidebar.frameLocator('iframe');

  await chatFrame.getByRole('textbox', { name: 'Message' }).fill('Help');
  await expect(reportFrame.getByRole('heading')).toBeVisible();
});
```

**JavaScript**
```javascript
const { test, expect } = require('@playwright/test');

test('interact with the correct iframe among many', async ({ page }) => {
  await page.goto('/dashboard');

  const chatFrame = page.frameLocator('iframe[title="Live chat"]');
  const reportFrame = page.frameLocator('iframe[name="analytics-report"]');
  const adFrame = page.frameLocator('iframe[src*="ads.example.com"]');
  const thirdFrame = page.frameLocator('iframe').nth(2);

  await chatFrame.getByRole('textbox', { name: 'Message' }).fill('Help');
  await expect(reportFrame.getByRole('heading')).toBeVisible();
});
```

### Nested Iframes

**Use when**: An iframe contains another iframe (common in complex widget hierarchies, ad containers, or embedded third-party tools).
**Avoid when**: There is only one level of iframe nesting.

**TypeScript**
```typescript
import { test, expect } from '@playwright/test';

test('interact with deeply nested iframe content', async ({ page }) => {
  await page.goto('/embed-page');

  // Chain frameLocator calls for each level of nesting
  const outerFrame = page.frameLocator('#widget-container');
  const innerFrame = outerFrame.frameLocator('#payment-form');

  await innerFrame.getByLabel('Amount').fill('99.99');
  await innerFrame.getByRole('button', { name: 'Confirm' }).click();

  // Three levels deep
  const deepFrame = page
    .frameLocator('#level-1')
    .frameLocator('#level-2')
    .frameLocator('#level-3');
  await expect(deepFrame.getByText('Success')).toBeVisible();
});
```

**JavaScript**
```javascript
const { test, expect } = require('@playwright/test');

test('interact with deeply nested iframe content', async ({ page }) => {
  await page.goto('/embed-page');

  const outerFrame = page.frameLocator('#widget-container');
  const innerFrame = outerFrame.frameLocator('#payment-form');

  await innerFrame.getByLabel('Amount').fill('99.99');
  await innerFrame.getByRole('button', { name: 'Confirm' }).click();

  const deepFrame = page
    .frameLocator('#level-1')
    .frameLocator('#level-2')
    .frameLocator('#level-3');
  await expect(deepFrame.getByText('Success')).toBeVisible();
});
```

### Cross-Origin Iframes

**Use when**: The iframe loads content from a different domain (payment providers, OAuth flows, third-party embeds).
**Avoid when**: The iframe is same-origin.

Playwright handles cross-origin iframes transparently. `frameLocator()` works regardless of origin. No special configuration is needed.

**TypeScript**
```typescript
import { test, expect } from '@playwright/test';

test('complete OAuth login in cross-origin iframe', async ({ page }) => {
  await page.goto('/login');
  await page.getByRole('button', { name: 'Sign in with Google' }).click();

  // The OAuth provider renders in a cross-origin iframe or popup
  // For iframes:
  const oauthFrame = page.frameLocator('iframe[src*="accounts.google.com"]');
  await oauthFrame.getByLabel('Email').fill('user@gmail.com');
  await oauthFrame.getByRole('button', { name: 'Next' }).click();
});

test('cross-origin payment widget', async ({ page }) => {
  await page.goto('/checkout');

  // Stripe, PayPal, etc. load in cross-origin iframes
  const stripeFrame = page.frameLocator('iframe[src*="js.stripe.com"]');

  // All locator methods work across origins
  await stripeFrame.getByLabel('Card number').fill('4242424242424242');
  await stripeFrame.getByLabel('MM / YY').fill('12 / 28');
  await stripeFrame.getByLabel('CVC').fill('123');
});
```

**JavaScript**
```javascript
const { test, expect } = require('@playwright/test');

test('cross-origin payment widget', async ({ page }) => {
  await page.goto('/checkout');

  const stripeFrame = page.frameLocator('iframe[src*="js.stripe.com"]');
  await stripeFrame.getByLabel('Card number').fill('4242424242424242');
  await stripeFrame.getByLabel('MM / YY').fill('12 / 28');
  await stripeFrame.getByLabel('CVC').fill('123');
});
```

### Using the Frame API for Advanced Scenarios

**Use when**: You need to access the frame's URL, wait for frame navigation, or run `evaluate` inside the frame.
**Avoid when**: `frameLocator()` covers your needs. It is simpler and auto-waits.

**TypeScript**
```typescript
import { test, expect } from '@playwright/test';

test('use Frame API for URL checks and evaluate', async ({ page }) => {
  await page.goto('/dashboard');

  // Get the Frame object (not FrameLocator)
  const frame = page.frame({ url: /analytics\.example\.com/ });
  expect(frame).not.toBeNull();

  // Check the frame's URL
  expect(frame!.url()).toContain('analytics.example.com');

  // Run JavaScript inside the frame
  const title = await frame!.evaluate(() => document.title);
  expect(title).toBe('Analytics Dashboard');

  // Wait for a frame to navigate
  const frameNavPromise = page.waitForEvent('framenavigated', {
    predicate: (f) => f.url().includes('/reports'),
  });
  await page.frameLocator('iframe[name="analytics"]')
    .getByRole('link', { name: 'Reports' }).click();
  await frameNavPromise;
});
```

**JavaScript**
```javascript
const { test, expect } = require('@playwright/test');

test('use Frame API for URL checks and evaluate', async ({ page }) => {
  await page.goto('/dashboard');

  const frame = page.frame({ url: /analytics\.example\.com/ });
  expect(frame).not.toBeNull();

  expect(frame.url()).toContain('analytics.example.com');

  const title = await frame.evaluate(() => document.title);
  expect(title).toBe('Analytics Dashboard');
});
```

### Shadow DOM -- Automatic Piercing

**Use when**: Your app uses Web Components with open Shadow DOM. This is the default behavior -- no special configuration needed.
**Avoid when**: The shadow root is closed (see workaround below).

Playwright's `locator()`, `getByRole()`, `getByText()`, and all semantic locators pierce open Shadow DOM by default.

**TypeScript**
```typescript
import { test, expect } from '@playwright/test';

test('interact with web components using Shadow DOM', async ({ page }) => {
  await page.goto('/design-system-demo');

  // getByRole pierces shadow roots automatically
  await page.getByRole('button', { name: 'Open menu' }).click();

  // locator() with CSS also pierces
  await page.locator('my-dropdown').getByRole('option', { name: 'Settings' }).click();

  // Nested web components — each shadow root is pierced
  await page
    .locator('my-app')
    .locator('my-sidebar')
    .getByRole('link', { name: 'Dashboard' })
    .click();

  // Assertions pierce too
  await expect(page.locator('my-card').getByText('Welcome back')).toBeVisible();

  // getByTestId pierces shadow DOM
  await expect(page.getByTestId('user-avatar')).toBeVisible();
});
```

**JavaScript**
```javascript
const { test, expect } = require('@playwright/test');

test('interact with web components using Shadow DOM', async ({ page }) => {
  await page.goto('/design-system-demo');

  await page.getByRole('button', { name: 'Open menu' }).click();
  await page.locator('my-dropdown').getByRole('option', { name: 'Settings' }).click();

  await page
    .locator('my-app')
    .locator('my-sidebar')
    .getByRole('link', { name: 'Dashboard' })
    .click();

  await expect(page.locator('my-card').getByText('Welcome back')).toBeVisible();
});
```

### Closed Shadow DOM Workaround

**Use when**: A third-party component uses `attachShadow({ mode: 'closed' })`, which blocks Playwright's auto-piercing.
**Avoid when**: The shadow root is open (the default). Auto-piercing handles open roots.

Override `attachShadow` before the page loads to force open mode.

**TypeScript**
```typescript
import { test, expect } from '@playwright/test';

test('access closed shadow DOM by forcing open mode', async ({ page }) => {
  // Intercept attachShadow before the page scripts run
  await page.addInitScript(() => {
    const originalAttachShadow = Element.prototype.attachShadow;
    Element.prototype.attachShadow = function (init: ShadowRootInit) {
      return originalAttachShadow.call(this, { ...init, mode: 'open' });
    };
  });

  await page.goto('/third-party-widget');

  // Now the previously closed shadow root is accessible
  await page.locator('closed-component').getByRole('button', { name: 'Action' }).click();
  await expect(page.locator('closed-component').getByText('Done')).toBeVisible();
});
```

**JavaScript**
```javascript
const { test, expect } = require('@playwright/test');

test('access closed shadow DOM by forcing open mode', async ({ page }) => {
  await page.addInitScript(() => {
    const originalAttachShadow = Element.prototype.attachShadow;
    Element.prototype.attachShadow = function (init) {
      return originalAttachShadow.call(this, { ...init, mode: 'open' });
    };
  });

  await page.goto('/third-party-widget');

  await page.locator('closed-component').getByRole('button', { name: 'Action' }).click();
  await expect(page.locator('closed-component').getByText('Done')).toBeVisible();
});
```

### Web Components with Slots and Custom Events

**Use when**: Testing web components that use `<slot>` for content projection or dispatch custom events.
**Avoid when**: The component does not use slots or custom events.

**TypeScript**
```typescript
import { test, expect } from '@playwright/test';

test('slotted content is visible through web component', async ({ page }) => {
  await page.goto('/components-demo');

  // Content projected into a <slot> is in the light DOM (not shadow)
  // Playwright sees it at its original location
  const card = page.locator('my-card');
  await expect(card.getByRole('heading', { name: 'Product Title' })).toBeVisible();
  await expect(card.getByText('Product description here')).toBeVisible();
});

test('listen for custom events from web components', async ({ page }) => {
  await page.goto('/components-demo');

  // Set up a listener for a custom event
  const eventPromise = page.evaluate(() => {
    return new Promise<{ detail: unknown }>((resolve) => {
      document.querySelector('my-color-picker')!.addEventListener(
        'color-change',
        (e: Event) => resolve({ detail: (e as CustomEvent).detail }),
        { once: true }
      );
    });
  });

  // Trigger the event by interacting with the component
  await page.locator('my-color-picker').getByRole('button', { name: 'Red' }).click();

  const event = await eventPromise;
  expect(event.detail).toEqual({ color: '#ff0000' });
});
```

**JavaScript**
```javascript
const { test, expect } = require('@playwright/test');

test('slotted content is visible through web component', async ({ page }) => {
  await page.goto('/components-demo');

  const card = page.locator('my-card');
  await expect(card.getByRole('heading', { name: 'Product Title' })).toBeVisible();
  await expect(card.getByText('Product description here')).toBeVisible();
});

test('listen for custom events from web components', async ({ page }) => {
  await page.goto('/components-demo');

  const eventPromise = page.evaluate(() => {
    return new Promise((resolve) => {
      document.querySelector('my-color-picker').addEventListener(
        'color-change',
        (e) => resolve({ detail: e.detail }),
        { once: true }
      );
    });
  });

  await page.locator('my-color-picker').getByRole('button', { name: 'Red' }).click();

  const event = await eventPromise;
  expect(event.detail).toEqual({ color: '#ff0000' });
});
```

## Decision Guide

| Scenario | Approach | Why |
|---|---|---|
| Content inside `<iframe>` | `page.frameLocator('selector')` | Returns a scoped locator for the iframe document |
| Multiple iframes on page | Use `title`, `name`, or `src` attribute selectors | More stable than index-based `nth()` |
| Nested iframes | Chain `frameLocator().frameLocator()` | Each call scopes one level deeper |
| Cross-origin iframe | Same as any iframe -- `frameLocator()` | Playwright handles cross-origin transparently |
| URL check or `evaluate` inside frame | `page.frame({ url })` (Frame API) | FrameLocator does not expose URL or evaluate |
| Open Shadow DOM | Standard locators -- no changes needed | Playwright pierces open shadow roots by default |
| Closed Shadow DOM | `addInitScript` to override `attachShadow` | Forces closed roots to open before page loads |
| Slotted content in Web Components | Locate within the custom element tag | Slotted content is light DOM, accessible normally |
| Non-piercing CSS (rare) | `css:light=selector` | Explicitly restricts to light DOM only |

## Anti-Patterns

| Don't Do This | Problem | Do This Instead |
|---|---|---|
| `page.locator('#element-inside-iframe')` | Locators do not cross iframe boundaries | `page.frameLocator('iframe').locator('#element')` |
| `page.frameLocator('iframe').frameLocator('iframe')` without specific selectors | Matches wrong iframes when multiple exist | Use specific attributes: `frameLocator('iframe[title="..."]')` |
| `page.$('>>> .shadow-element')` | `>>>` piercing selector is not standard in Playwright | Use `page.locator('host-element').getByRole(...)` -- auto-piercing works |
| Using `contentFrame()` on a locator for routine interactions | More complex API than `frameLocator` for simple cases | Use `frameLocator()` -- simpler, auto-waits |
| `page.evaluate` to query inside shadow DOM | Bypasses Playwright's auto-waiting and retry logic | Use `page.locator()` which auto-pierces |
| Hardcoding iframe index (`nth(0)`) when attributes are available | Index changes when iframes are added/removed | Use `title`, `name`, or `src` pattern |

## Troubleshooting

| Symptom | Cause | Fix |
|---|---|---|
| Locator times out inside iframe | Using `page.locator()` instead of `frameLocator().locator()` | Switch to `page.frameLocator('selector').locator(...)` |
| `frameLocator` returns no elements | Iframe not yet loaded when locator resolves | `frameLocator` auto-waits; check that the iframe selector matches |
| Cross-origin iframe content inaccessible | Rare: specific browser security policy | Playwright handles cross-origin; ensure you are not using `page.frame()` with wrong URL |
| Shadow DOM element not found with `locator()` | Shadow root is closed (`mode: 'closed'`) | Use `addInitScript` to override `attachShadow` to force open mode |
| `getByRole` finds elements from wrong shadow root | Multiple web components have elements with the same role and name | Scope the locator: `page.locator('my-specific-component').getByRole(...)` |
| Slotted content not found | Searching inside shadow root instead of light DOM | Slotted content stays in light DOM; locate it through the parent custom element |
| `frame.evaluate()` returns null | Frame navigated away or was removed from DOM | Re-acquire the frame reference after navigation |

## Related

- [core/locators.md](locators.md) -- locator fundamentals including frame and shadow DOM basics
- [core/browser-apis.md](browser-apis.md) -- testing browser APIs that may live inside iframes
- [core/canvas-and-webgl.md](canvas-and-webgl.md) -- canvas elements inside iframes or web components
- [core/debugging.md](debugging.md) -- using Playwright Inspector to identify iframe boundaries and shadow roots
