ScreenshotNeo

BlogHow-to

How to Capture Shadow DOM Elements with Puppeteer Screenshots

Use Puppeteer’s deep selectors to find elements in open Shadow DOM roots, then capture them reliably with ElementHandle.screenshot().

By the ScreenshotNeo team1 October 20268 min read

Use Puppeteer’s deep selector combinator to cross an open Shadow DOM boundary, then call ElementHandle.screenshot() on the returned element. For example, my-widget >>> button finds a button at any depth inside an open shadow root. Use >>>> when the target must be in the host’s immediate shadow root.

Plain CSS selectors do not pierce Shadow DOM boundaries. Puppeteer adds these deep combinators for open roots. After selecting the target, Puppeteer scrolls it into view if needed and captures the element with ElementHandle.screenshot(). See the official Puppeteer screenshots guide and selector guide.

Complete Puppeteer example

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();
  await page.goto('https://example.com', {waitUntil: 'networkidle2'});

  // Replace my-widget and button with selectors from your page.
  // >>> crosses open shadow roots at any depth.
  const target = await page.waitForSelector('my-widget >>> button');
  if (!target) throw new Error('Target element was not found');

  await target.screenshot({path: 'shadow-element.png'});
} finally {
  await browser.close();
}

This example assumes the component has rendered an open shadow root and that the button is visible in its final state. Navigation finishing does not necessarily mean that a client-rendered component is ready, so wait for the target or for a component-specific readiness condition.

How Puppeteer’s Shadow DOM selectors work

Selector Scope Use it when
host >>> target Deep descendant The target can be nested at any depth in an open shadow tree.
host >>>> target Deep child The target must be inside the host’s immediate shadow root.
host target Normal CSS descendant Only when both nodes are in the same tree; it does not cross a shadow boundary.

The deep combinators are documented for open shadow roots. They are not a documented way to query a closed shadow root. Puppeteer also cautions that deep combinators apply to the first depth of CSS selectors, so keep the selector structure simple and verify it against the actual component tree.

Choose stable host selectors

Start with a selector that identifies one host component, then cross into the shadow tree:

await page.waitForSelector('product-card[data-id="42"] >>> button.buy');
await page.waitForSelector('my-widget >>>> .status');

If a page contains multiple instances, add a stable attribute, class, or surrounding context. Avoid relying on generated class names or a broad selector such as my-widget >>> button when several widgets exist.

Wait for the component’s final visual state

Shadow components often render after navigation through client-side JavaScript. Wait for the actual target rather than assuming networkidle2 is sufficient.

await page.goto('https://example.com/dashboard', {waitUntil: 'domcontentloaded'});

const target = await page.waitForSelector(
  'account-summary >>> .balance',
  {timeout: 15000}
);

await target.screenshot({path: 'balance.png'});

For a known application state, perform the interaction before querying the element:

await page.waitForSelector('menu-button');
await page.click('menu-button');

const menu = await page.waitForSelector('menu-button >>> .menu-panel');
await menu.screenshot({path: 'open-menu.png'});

If the component exposes a meaningful readiness marker, wait for that marker or for a specific class/attribute. The official Puppeteer guidance recommends Locator APIs for general selection and interaction because they wait for elements to be present and ready; the documented element screenshot flow uses the resulting element handle with ElementHandle.screenshot(). See the Puppeteer page interaction guide.

Element screenshots versus full-page screenshots

Goal API Result
Capture one Shadow DOM component or descendant ElementHandle.screenshot() The selected element, scrolled into view when needed.
Capture the entire document page.screenshot({fullPage: true}) The full page, including content outside the component.
Capture the current viewport page.screenshot() What is visible in the viewport.
await page.screenshot({path: 'page.png', fullPage: true});

Use the element method when the deliverable is the component itself. Use Page.screenshot() when page context matters.

Prevent stale handles during rerenders

ElementHandle.screenshot() throws if the element has been detached from the DOM. This happens when a framework replaces a component during hydration, polling, route changes, or state updates.

await page.waitForSelector('my-widget >>> button');

// Trigger an update that may replace the component.
await page.click('#refresh');

// Query again after the update instead of reusing the old handle.
const stableTarget = await page.waitForSelector(
  'my-widget >>> button',
  {timeout: 15000}
);
await stableTarget.screenshot({path: 'updated-shadow-element.png'});

When a handle becomes stale, reacquire it after the component reaches its final state. If the page continuously rerenders, add an application-specific condition that identifies the completed state before taking the screenshot.

Closed roots and practical boundaries

A closed shadow root intentionally hides its internals from outside code. Puppeteer’s documented >>> and >>>> selectors target open roots, so do not treat them as a workaround for closed encapsulation.

For a closed component, use an interface the component author provides: a public screenshot mode, an exposed light-DOM wrapper, a test hook, or a page-level capture. If you control the component, consider exposing the required visual state through a stable host element rather than depending on private internals.

Screenshot options and visual details

Element screenshots use Puppeteer’s screenshot options, including an output path and image format options supported by Page.screenshot(). Set the viewport before selecting the target when responsive layout affects the result:

await page.setViewport({width: 1440, height: 900, deviceScaleFactor: 2});
await page.goto('https://example.com', {waitUntil: 'networkidle2'});

const card = await page.waitForSelector('profile-card >>> .card');
await card.screenshot({
  path: 'profile-card.png',
  type: 'png'
});

For reproducible images, control the viewport, device scale factor, color scheme, fonts, and animation state. Disable or freeze animations through page CSS when transitions can change the captured frame:

await page.addStyleTag({content: `
  *, *::before, *::after {
    animation: none !important;
    transition: none !important;
    caret-color: transparent !important;
  }
`});

Wait for fonts and important images when they affect the component. A network-idle condition is only a heuristic; a page can still update after the network becomes quiet.

Common errors and fixes

Error or symptom Likely cause Fix
Target element was not found Wrong host selector, wrong descendant, or component not rendered yet. Inspect the host, tighten the selector, and wait for the actual target state.
Normal CSS selector returns no match CSS cannot cross a shadow boundary. Use Puppeteer’s >>> or >>>> syntax for an open root.
Deep selector still returns no match The root is closed, or the selector assumes unsupported nested CSS syntax. Confirm the root is open and simplify the deep selector. Use a public component hook for closed roots.
Node is detached from document The component rerendered after selection. Wait for the final state and reacquire the element handle immediately before capture.
Wrong instance is captured The host selector matches multiple components. Add stable attributes or surrounding context to identify one host.
Blank or incomplete image Capture occurred before images, fonts, or client rendering finished. Wait for the target’s ready state and required resources; freeze animations if necessary.
Only the visible portion appears The element has a constrained scroll container or clipped content. Decide whether the element’s rendered box is the intended output; adjust page/component styles when a complete expanded state is required.

Performance, reliability and cost

  • Reuse the browser: launch one browser and create or reuse pages for multiple captures instead of launching a process per image.
  • Keep selectors narrow: stable host selectors reduce query ambiguity and retries.
  • Wait for a real condition: short, targeted waits usually avoid both premature images and unnecessary delays from large fixed sleeps.
  • Control page state: fixed viewport, device scale, fonts, animations and data make visual output more repeatable.
  • Handle failures explicitly: set navigation and selector timeouts, close pages in cleanup, and retry only after determining whether the page or selector changed.
  • Budget browser resources: concurrent pages consume memory and CPU; cap concurrency for large capture jobs.

Puppeteer itself does not provide a screenshot billing model. Your cost is the infrastructure and browser runtime you operate, plus the engineering work required to handle rendering differences, consent banners, popups and failed loads.

Or skip the browser setup

ScreenshotNeo provides a website screenshot API and MCP server. It can capture a URL as PNG, JPEG, WebP or PDF, and supports element capture by CSS selector. Its cleanup steps accept cookie and consent banners and remove more than 60 known consent platforms, newsletter popups and chat widgets before capture; each step can be turned off.

One request is enough:

curl -G "https://api.screenshotneo.com/v1/shot" \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://stripe.com \
  -o shot.webp

See the ScreenshotNeo API documentation for the complete option list, including CSS selectors, full-page capture, custom JavaScript and CSS, waits, headers, cookies, user agents, geolocation, caching and signed links.

Python

import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)

Node.js

const q = new URLSearchParams({
  access_key: 'YOUR_API_KEY',
  url: 'https://stripe.com'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const buffer = Buffer.from(await res.arrayBuffer());
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', buffer));

ScreenshotNeo bills only clean shots. Bot checks or CAPTCHAs, blank pages, timeouts, failed loads and cache hits cost nothing, and each response reports the result through X-Page-Verdict and X-Billed headers. Its MCP server includes take_screenshot, get_page_info and capture_pdf tools for Claude, Cursor and other MCP clients.

The Free plan includes 1,000 screenshots per month with no card. Paid plans start at $5 for 3,000 screenshots; every feature is available on every plan. Create a free ScreenshotNeo account.

FAQ

Can Puppeteer capture a closed shadow root?

Not with the documented deep selector combinators. They are intended for open roots. Use a component-provided public hook or capture an exposed wrapper.

Should I use >>> or >>>>?

Use >>> when the target may be nested at any depth. Use >>>> when it must be in the host’s immediate shadow root.

Why does my selector work in DevTools but not in Puppeteer?

DevTools inspection can show nodes inside a shadow tree, while ordinary CSS selectors remain scoped to their tree. Use Puppeteer’s shadow-aware selector syntax and verify that the root is open.

When should I capture the host instead of a descendant?

Capture the host when its complete rendered component is the deliverable. Capture a descendant when you need only a button, card, status, or other specific region.

Which Puppeteer version should I use?

The official pages consulted displayed version 25.12.0 at research time. Check the version installed in your project before copying examples because APIs and behavior can change.