How to Take a Puppeteer Screenshot When a Page Uses Shadow DOM
Capture a whole page or an element inside an open shadow root with Puppeteer, including runnable code, selector choices, troubleshooting, and reliability tips.
To screenshot one element inside an open shadow root, use Puppeteer’s >>> deep descendant selector to get an element handle, then call ElementHandle.screenshot(). Use >>>> when the target is in the host’s immediate shadow root. To capture the entire rendered page, call page.screenshot(); you do not need to traverse the shadow tree.
Puppeteer’s [page interactions guide](https://pptr.dev/guides/page-interactions) documents the deep selectors, and its [screenshot guide](https://pptr.dev/guides/screenshots) documents page and element capture. The examples below use the documented Puppeteer API; check the docs for the version installed in your project.
1. Capture an element inside an open shadow root
Install Puppeteer in a Node.js project if it is not already installed:
npm install puppeteer
Save this as screenshot-shadow.mjs and run it with node screenshot-shadow.mjs. Replace the example URL, host, and target selector with those on your page.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
try {
const page = await browser.newPage();
await page.goto('https://example.com', { waitUntil: 'networkidle2' });
// >>> searches below the host through open shadow roots at any depth.
const target = await page.waitForSelector('my-widget >>> .target', {
timeout: 10_000,
});
if (!target) {
throw new Error('Target element not found: my-widget >>> .target');
}
await target.screenshot({ path: 'target.png' });
await target.dispose();
} finally {
await browser.close();
}
ElementHandle.screenshot() scrolls the element into view if needed, then captures it. It throws if the element has been detached from the DOM. See the [ElementHandle screenshot API](https://pptr.dev/api/puppeteer.elementhandle.screenshot).
Choose the selector for the target’s depth
| Selector | What it searches | Use it when |
|---|---|---|
my-widget >>> .target |
Descendants beneath the host at any depth through open shadow roots | The target may be nested under one or more shadow roots |
my-widget >>>> .target |
Descendants in the host’s immediate shadow root | The target is directly in that root |
Ordinary CSS selectors do not cross shadow boundaries. Puppeteer adds these custom combinators for open roots; they do not provide a way to query a closed shadow root. The combinators apply to the documented selector syntax, so keep the deep combinator between selector parts rather than assuming arbitrary nested CSS functions will pierce roots. See the [Puppeteer shadow DOM selector documentation](https://pptr.dev/guides/page-interactions#querying-elements-in-shadow-dom).
2. Capture the whole rendered page
If you need the page as a visitor sees it, you do not need to find a node inside the shadow tree. Page capture includes rendered shadow DOM content without an internal selector.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
try {
const page = await browser.newPage();
await page.goto('https://example.com', { waitUntil: 'networkidle2' });
await page.screenshot({ path: 'page.png', fullPage: true });
} finally {
await browser.close();
}
fullPage: true captures the full page rather than only its current viewport. For the current viewport, omit that option. Full-page capture and element capture solve different problems: use the former for the complete rendered document and the latter when you need a tightly scoped image of one component or internal node. See Puppeteer’s [Page screenshot API](https://pptr.dev/api/puppeteer.page.screenshot).
3. Find an element through page-context DOM access
If a deep selector is awkward—for example, because you need to inspect a known sequence of hosts—use evaluateHandle() to traverse open roots in the page context. This returns a handle to the element when the function returns an element.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
try {
const page = await browser.newPage();
await page.goto('https://example.com', { waitUntil: 'networkidle2' });
const target = await page.evaluateHandle(() => {
const host = document.querySelector('my-widget');
return host?.shadowRoot?.querySelector('.target') ?? null;
});
if (!target.asElement()) {
await target.dispose();
throw new Error('Target not found, or the host has no open shadow root');
}
await target.screenshot({ path: 'target.png' });
await target.dispose();
} finally {
await browser.close();
}
For nested hosts, explicitly walk each root, checking at each step:
const target = await page.evaluateHandle(() => {
const outer = document.querySelector('outer-widget');
const inner = outer?.shadowRoot?.querySelector('inner-widget');
return inner?.shadowRoot?.querySelector('.target') ?? null;
});
This only works when every traversed root is open and exposed through host.shadowRoot. evaluateHandle() runs the function in page context and retains the returned object as a handle; see the [API reference](https://pptr.dev/api/puppeteer.page.evaluatehandle).
4. Wait for the component and its content
Navigation completion does not guarantee that a client-rendered component has created its shadow root or populated the target. Wait for the actual target selector before capturing it. The first example uses waitForSelector() with a timeout so a missing component becomes a clear failure instead of an indefinite wait.
When you use locators for interactions around the capture, Puppeteer recommends locators because they wait for an element and its state. The screenshot operation itself is available on an element handle, so obtain the handle when you need ElementHandle.screenshot(). If content is still loading inside the component, wait on a selector that represents the completed content, or wait for a known application-specific condition before taking the image.
Choose a navigation condition that matches the site. networkidle2 can be convenient for pages that settle quickly, but persistent network activity can prevent it from settling. In that case, wait for a meaningful component selector or app state instead of treating network quiet as proof that the desired content is ready.
5. Troubleshoot missing or incorrect captures
| Symptom | Likely cause | Fix |
|---|---|---|
waitForSelector times out |
The host or internal target has not rendered, the selector is wrong, or the root is closed | Confirm the host exists; wait for the component’s ready state; verify the class inside the open root; use page-context inspection to check host.shadowRoot |
| Target is found at one depth but not another | >>> and >>>> express different traversal depth |
Use >>> for descendants at any depth, or >>>> for the immediate root |
| Screenshot throws that the element is detached | The framework replaced or removed the node between lookup and capture | Wait for the final rendered state, reacquire the handle immediately before capture, and avoid triggering a re-render between lookup and screenshot |
| Image contains the wrong part of the page or appears clipped | You selected a wrapper or a different matching node; the element’s own dimensions or overflow clip its content | Narrow the selector, inspect the element’s bounding box and styles, and capture the intended container or use a page screenshot if the full view is required |
| Sticky UI moves or the page changes before capture | Element screenshotting may scroll the target into view | Account for the scroll in the page state; if scroll position and fixed overlays matter, capture the page at a controlled viewport and scroll position |
| Screenshot is blank or incomplete despite a match | The element exists before its fonts, images, or asynchronous content are ready | Wait for the relevant assets or component-specific ready signal before capture; do not rely only on a DOM match |
6. Reliability, performance, and output considerations
- Prefer a stable target. A component’s semantic or stable class selector is less brittle than selectors tied to generated markup. Update the selector when the component’s DOM contract changes.
- Scope the screenshot. Element capture is useful for a single component and avoids saving a full-page image when that is not needed. Full-page capture can create much larger output for long documents.
- Wait for readiness, not just navigation. A successful
goto()means the selected navigation condition completed; it does not guarantee application-specific rendering, third-party content, fonts, or images are ready. - Dispose handles and close the browser. Dispose element handles when finished, especially in repeated captures, and close the browser in a
finallyblock so failures do not leave processes running. - Expect scroll effects. Element capture scrolls into view if needed. Sticky headers, lazy-loaded content, and scroll-triggered behavior can affect the result; make the page state deterministic before capture.
- Control the environment. Set the viewport and any required browser state consistently when comparing screenshots. Screenshots can differ with viewport, loaded assets, animation timing, and page state.
There is no published performance figure in the cited Puppeteer documentation for shadow DOM screenshots. Measure your own workload if capture time, output size, or concurrency affects your application. Keep browser concurrency within the memory and CPU limits of the machine running it; large full-page captures and many simultaneous pages increase resource use.
7. Or skip the browser setup
If the goal is a screenshot of the rendered page, [ScreenshotNeo](https://screenshotneo.com) can return an image with one GET request. It is a website screenshot API and MCP server. See the [ScreenshotNeo API docs](https://screenshotneo.com/docs/) for parameters and response details.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com -o shot.webp
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://example.com"},
timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await Bun.write('shot.webp', res);
ScreenshotNeo accepts cookie or consent banners like a visitor and removes 60+ known consent platforms, newsletter popups, and chat widgets before capture; each step can be turned off. Bot checks or CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing, and response headers report the page verdict and billing status. Its MCP server provides take_screenshot, get_page_info, and capture_pdf 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.
Create a free ScreenshotNeo account for 1,000 screenshots a month with no card.
FAQ
Can Puppeteer screenshot a closed shadow root?
The documented deep selectors and the page-context shadowRoot approach work with open roots. The cited guidance does not establish a supported way to traverse a closed root. For the visible rendered page, capture the page rather than selecting an internal node.
Does a page screenshot include shadow DOM?
Yes. A page screenshot captures the rendered page, so you do not need to query into the shadow tree to include its visible content.
Should I use a locator or waitForSelector()?
Puppeteer recommends locators for selecting and interacting because they handle waiting and action readiness. waitForSelector() remains a lower-level option that returns a handle suitable for ElementHandle.screenshot().


