Shadow DOM is a browser technology that encapsulates a web component’s internal structure and styling, preventing them from leaking out or being affected by the main document’s DOM.

Let’s see it in action. Imagine a custom element, <my-counter>, that has a button to increment a value displayed in a span.

<my-counter></my-counter>

And its JavaScript definition:

class MyCounter extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' }); // Create a shadow root

    const wrapper = document.createElement('div');
    this.counterSpan = document.createElement('span');
    this.counterSpan.textContent = '0';
    const incrementButton = document.createElement('button');
    incrementButton.textContent = 'Increment';
    incrementButton.addEventListener('click', () => {
      this.count++;
      this.counterSpan.textContent = this.count;
    });

    wrapper.appendChild(this.counterSpan);
    wrapper.appendChild(incrementButton);
    this.shadowRoot.appendChild(wrapper);

    this.count = 0;
  }
}

customElements.define('my-counter', MyCounter);

Now, if we were to inspect this in a regular browser, we’d see the <my-counter> element, but its internal div, span, and button would be hidden within the shadow DOM.

Playwright can interact with this. Normally, you’d use page.locator('selector') to find an element. But for elements inside the shadow DOM, a direct selector on the main document won’t work. You need to pierce the shadow boundary.

The key is Playwright’s locator.getShadowText() and, more powerfully, locator.locator('css_selector_within_shadow'). The locator() method, when called on a locator that already points to a shadow host element, will automatically descend into its shadow DOM.

So, to interact with the span inside our <my-counter> component:

const counterElement = page.locator('my-counter');
const countDisplay = counterElement.locator('span'); // This automatically looks inside the shadow DOM of 'my-counter'
await expect(countDisplay).toHaveText('0');

And to click the button:

const incrementButton = counterElement.locator('button');
await incrementButton.click();
await expect(countDisplay).toHaveText('1');

This locator.locator() chaining is the primary mechanism. It’s as if Playwright understands the shadow boundary and knows to look inside when you chain .locator() calls starting from a known shadow host.

The mode: 'open' in attachShadow({ mode: 'open' }) is crucial here. If it were mode: 'closed', the shadow root would be inaccessible from JavaScript, and Playwright (or any external script) wouldn’t be able to interact with its contents. While closed mode is rarely used in practice for components intended for public use, it’s important to know it exists.

The mental model is that Playwright’s locator object can represent not just a DOM node in the main document, but also a shadow host. When you call .locator() on a shadow host locator, you’re essentially saying, "find me a descendant within this shadow DOM." The CSS selector you pass then operates relative to the shadow root, not the main document.

A common pitfall is trying to use page.locator('span') directly to find the span inside the shadow DOM. This will only find span elements in the light DOM, and you’ll get an error like "locator.getByText('0') is not visible." Playwright correctly doesn’t traverse into shadow DOM roots unless explicitly told to do so via the chained locator() calls.

Most people don’t realize that the locator.locator() method inherently handles shadow DOM traversal when the parent locator is a shadow host. You don’t need a special getShadowLocator() or anything explicit; the existing API is designed to work seamlessly. It’s a form of implicit context switching that makes interacting with web components feel natural once you understand the chaining.

The next challenge is handling dynamically added shadow roots or components that attach their shadow DOM later in the lifecycle.

Want structured learning?

Take the full Playwright course →