Posted on May 14, 2026 | 12 min read
To take a screenshot of a specific element in Puppeteer, use elementHandle.screenshot() after selecting the element with page.$(). For dynamic content, use page.locator() (Puppeteer v21+) to wait for the element automatically. For custom regions or padded captures, use page.screenshot() with the clip option set to the element's boundingBox().
This guide walks through all three methods with working code, then covers the edge cases that quietly break element screenshots in production: hidden elements, blank captures, off-screen targets, lazy-loaded content, and transparent backgrounds. The last section covers when it makes sense to skip the Puppeteer setup entirely and use a hosted screenshot API instead.
An element screenshot captures only the bounding box of one DOM node, not the full page and not the viewport. If you have a page with a header, a chart, and a footer, an element screenshot of the chart returns just the chart, sized exactly to its rendered dimensions. The rest of the page is never written to the image file.
Developers reach for this pattern in four common cases: visual regression tests where only one component changes between runs, UI documentation that needs isolated component shots, OG image generation that captures a single hero card, and dashboard exports where one chart or table needs to be sent as an image. Full-page screenshots work for all of these, but cropping after the fact is brittle (one CSS change shifts every coordinate) and wastes bytes. Element-level captures avoid both problems.
You need Node.js 22.12+ and a project where you want to add Puppeteer. Install the standard package:
npm install puppeteerThe above command downloads compatible Chrome during installation. Puppeteer ships with a matched version of Chromium, so you do not need to install Chrome separately. If you already have Chrome installed and want to skip the Chromium download (smaller install footprint, for example on a CI runner), use the lightweight package instead:
npm install puppeteer-coreAlternatively, puppeteer-core install as a library, without downloading Chrome.
One more thing before you write any screenshot code: pick a viewport size and set it explicitly. Puppeteer's default is 800x600, which is small enough that any element below the fold will require scrolling. Set a realistic viewport in every script:
await page.setViewport({ width: 1280, height: 800 });Now you are ready for the three methods.
The first method is the one most developers find first. You locate the element with a CSS selector, then call .screenshot() directly on the element handle. Puppeteer reads the element's bounding box and crops automatically.
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto('https://example.com', { waitUntil: 'networkidle2' });
// Wait for the element to appear in the DOM
await page.waitForSelector('#hero-section');
const element = await page.$('#hero-section');
if (element) {
await element.screenshot({ path: 'hero-section.png' });
console.log('Screenshot saved.');
}
await browser.close();
})();The page.$() method works exactly like document.querySelector(). Pass any valid CSS selector and it returns an ElementHandle. From there, elementHandle.screenshot() captures only the element's bounding box.
Puppeteer will scroll the element into view automatically before capturing, so you do not need to handle that yourself for this method. The element does need to be visible (not display: none, not visibility: hidden), and any parent with overflow: hidden may clip the result in ways you do not expect. The clipped-parent case is the one I have hit most often in real projects: the screenshot writes out the element's full content, but if a parent container visually crops it, the actual rendered image looks the same as what is on screen, which is sometimes what you want and sometimes not.
This method is the right choice when you have a stable selector, the element is always present in the DOM by the time you screenshot it, and you do not need padding around the captured region.
If you are on Puppeteer v21 or later, the page.locator() API is the better default. It waits for the element to become visible and stable before interacting with it, which means you can drop the separate waitForSelector() call.
const puppeteer = require('puppeteer');
const fs = require('fs');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto('https://example.com', { waitUntil: 'networkidle2' });
// locator() handles the wait internally
const screenshot = await page.locator('.product-card').screenshot();
fs.writeFileSync('product-card.png', screenshot);
await browser.close();
})();The practical difference between page.$() and page.locator() is what happens when the element is not ready yet. With page.$(), you get null back and your code either crashes or silently fails. With page.locator(), Puppeteer waits with a built-in retry loop until the element is visible and stable. For pages with async content, lazy-loaded components, or any rendering that depends on JavaScript finishing after networkidle2, this is the safer choice.
page.locator() is also the method I reach for in CI environments. Build-server timing varies enough between runs that scripts using page.$() will pass locally and fail in CI for no obvious reason. Switching to locator() removes a class of flaky-test problem entirely.
The third method gives you the most control. You read the element's bounding box yourself, then pass those coordinates to page.screenshot() using the clip option.
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto('https://example.com', { waitUntil: 'networkidle2' });
await page.waitForSelector('.chart-container');
const element = await page.$('.chart-container');
const box = await element.boundingBox();
// Add 20px padding around the element
await page.screenshot({
path: 'chart-padded.png',
clip: {
x: box.x - 20,
y: box.y - 20,
width: box.width + 40,
height: box.height + 40,
},
});
await browser.close();
})();boundingbox() returns the element's x, y, width, and height relative to the page. This is exactly what elementHandle.screenshot() does internally, except now you can modify the values before they go into the capture call.
Use this method when you need padding around the element (the example above adds 20px on every side), when the element does not have a clean selector and you have computed its position some other way, or when you want to capture a region that includes the element plus some surrounding context that a strict bounding-box crop would lose.
One thing to know: clip does not respect overflow rules. If you clip a region that contains content extending beyond a parent container with overflow: hidden, the clip captures the underlying content anyway, ignoring the parent's visual clipping. This is occasionally exactly what you want, and occasionally not. Worth knowing before you reach for this method as the default.

The three methods above work cleanly when the element is visible, the page is fully loaded, and the parent containers are not doing anything unusual. In production, none of those assumptions hold by default. Here are the edge cases that account for most "my Puppeteer element screenshot is broken" support questions.
If the element has display: none or visibility: hidden, Puppeteer cannot screenshot it because it has no renderable bounding box. The screenshot call will either throw or return an empty image. Before capturing, make the element visible by triggering whatever opens it: click the toggle, dispatch the event, remove the hiding class.
// Example: open a collapsed section before screenshotting its contents
await page.click('#expand-toggle');
await page.waitForSelector('.expanded-content', { visible: true });
await page.locator('.expanded-content').screenshot({ path: 'expanded.png' });The { visible: true } option on waitForSelector() is what you want, not the default. The default only waits for the element to exist in the DOM, which is not the same as being rendered.
This usually means the element exists in the DOM but its content has not finished rendering when the screenshot fires. Fonts have not loaded, images are still fetching, or JavaScript is still painting the component. Three fixes, in order of how often I reach for them:
If you have tried all three and the screenshot is still blank, the element is probably inside an iframe or a shadow DOM. Both require different APIs that fall outside the scope of this guide.
If the element exists but is outside the current viewport (below the fold, for instance), elementHandle.screenshot() will scroll it into view automatically. The clip method will not. If you are using Method 3 and the element is below the fold, scroll it into view first:
await page.evaluate((el) => el.scrollIntoView(), element);Then read boundingBox() and clip. Bounding-box coordinates are relative to the page, not the viewport, so the scroll matters less than it does for some other Puppeteer APIs, but reading the box after scrolling gives more reliable values.
For pages that load content as the user scrolls, the element may not exist in the DOM until the scroll happens. Wait for the scroll trigger explicitly:
// Scroll to the bottom to trigger lazy load, then wait for the target element
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForSelector('.lazy-loaded-component', { visible: true, timeout: 10000 });
await page.locator('.lazy-loaded-component').screenshot({ path: 'lazy.png' });The timeout matters here. Lazy-loaded content can take a few seconds to fire its data requests and render. A 10-second timeout is a reasonable upper bound for most cases.
By default, Puppeteer screenshots have a white background, even for elements that are visually transparent on the page. If you want a transparent PNG (useful when capturing a UI component to use elsewhere), pass omitBackground: true:
await page.locator('.icon-button').screenshot({
path: 'icon-button.png',
omitBackground: true,
});This only works with PNG. JPEG does not support transparency. If you need a transparent capture, you must use PNG (or WebP).
elementHandle.screenshot() captures exactly what is inside the element's bounding box as reported by the browser. This includes visible box shadows and outlines as long as they fall inside the bounding box (some shadows do not, depending on the box-shadow spread value). Content that overflows outside the element's defined boundary will not appear, even if it is visible on the page.
If you need to capture an element with its shadow intact and the shadow extends beyond the bounding box, switch to Method 3 (clip) and add padding equal to the shadow's spread radius.
Puppeteer supports three image formats for screenshots:
| Format | Best For | Transparency Support |
|---|---|---|
| PNG (default) | UI components, sharp text, images that need transparency | Yes |
| JPEG | Photos, large full-page captures where file size matters | No |
| WebP | Balanced quality and size, modern image pipelines | Yes |
Pick the format with the type option:
await page.locator('.product-card').screenshot({
path: 'product-card.webp',
type: 'webp',
quality: 85,
});The quality option only applies to JPEG and WebP (not PNG, which is lossless). For most UI capture work, PNG is the right default. Switch to WebP if file size matters and your downstream tools support it. Switch to JPEG only for photographic content.
Here is the decision matrix I use when picking between the three methods on a new project:
| If you have... | Use this method | Why |
|---|---|---|
| A stable CSS selector and static content | Method 1: elementHandle.screenshot() | Simplest API, no extra setup |
| Dynamic content, async rendering, or CI environments | Method 2: page.locator() | Built-in waiting removes flakiness |
| Padding requirements, no clean selector, or partial regions | Method 3: clip with boundingBox() | Maximum control over the captured region |
For most projects, Method 2 (page.locator()) is the sensible default. It costs you nothing in code complexity (one extra letter compared to page.$()) and saves you from an entire class of timing bugs. Reach for Method 1 only when you are on a Puppeteer version older than v21. Reach for Method 3 when you need fine-grained control over the captured region.

Puppeteer earns its place when you need full programmatic control over the browser: custom interactions, multi-step workflows, scraping pipelines that branch and retry. For element screenshots specifically, that control comes with overhead you may not need.
Running Puppeteer in production means: a Node.js server to host the scripts, Chromium processes to manage (each one uses around 250MB of memory), crash handling for when those processes die, queue workers if you are capturing more than a few elements per minute, and version updates every time Puppeteer or Chromium ships a breaking change. None of that work makes your screenshots better. It just keeps the infrastructure running.
For teams capturing element screenshots as part of a product feature (OG image generation, automated dashboards, scheduled component captures, visual regression in a non-developer-led team), a hosted screenshot API replaces all of it with an HTTP request.
The element screenshot on ScreenshotAPI.net maps directly onto the three Puppeteer methods covered above:
| Puppeteer method | ScreenshotAPI parameter | Effect |
|---|---|---|
| elementHandle.screenshot() with page.$(selector) | selector=<css> | Captures only the matched element |
| page.evaluate(el => el.scrollIntoView()) before clip | scroll_to_element=<css> | Scrolls to the element before capture |
| page.screenshot({ clip: { x, y, width, height } }) | clip[x], clip[y], clip[width], clip[height] | Captures a precise region by coordinates |
A complete element capture in Node.js using ScreenshotAPI is three lines:
const axios = require('axios');
const response = await axios.get('https://shot.screenshotapi.net/v3/screenshot', {
params: {
token: 'YOUR_API_TOKEN',
url: 'https://example.com',
selector: '#hero-section',
file_type: 'png',
},
responseType: 'arraybuffer',
});
require('fs').writeFileSync('hero-section.png', response.data);No Puppeteer install. No Chromium process. No retry logic for blank screenshots. The API also handles the edge cases above: it waits for the page to render, supports scroll_to_element, accepts custom viewport sizes, blurs sensitive elements via blur_selector, and removes unwanted elements via remove_selector before the capture happens.
Puppeteer wins when your workflow needs more than a single screenshot call. If you are scripting a multi-step flow (log in, fill a form, navigate three pages, capture five elements along the way), you need browser control beyond what a screenshot API exposes. If you are doing visual regression testing as part of a larger Node.js test suite, integrating with your existing tooling is easier than hitting an external API. And if you have privacy or compliance constraints that prevent sending URLs to a third-party service, self-hosted Puppeteer is the only option.
For everything else (especially product features that capture element screenshots on demand), the hosted approach removes infrastructure work that does not move your product forward.
Not directly. Puppeteer cannot screenshot an element with display: none or visibility: hidden because it has no renderable bounding box. Make the element visible first by clicking the trigger that opens it, dispatching the relevant event, or removing the hiding class. Then call waitForSelector with { visible: true } before the screenshot.
This is almost always a timing issue. The element exists in the DOM but its content has not finished rendering. Fix it in three steps: use waitUntil: 'networkidle2' in page.goto(), call waitForSelector(selector, { visible: true }) before the screenshot, and for image-heavy elements add a short manual wait. If all three still produce a blank, the element is likely inside an iframe or shadow DOM, which requires different APIs.
It captures exactly what is within the element's bounding box as reported by the browser. Box shadows and outlines are included if they fall inside that bounding box. Content that overflows outside the element's boundary is not included, even if it is visible on the page. If you need the shadow to be captured and it extends beyond the box, switch to Method 3 (clip with boundingBox()) and add padding equal to the shadow's spread.
page.$() returns the element handle immediately or null if the element is not found. You handle waiting yourself with waitForSelector. page.locator() (Puppeteer v21+) handles waiting internally, retrying until the element is visible and stable. For dynamic content and CI environments, page.locator() is the safer default because it removes a class of flaky-test problems.
Yes. Use page.$$(selector) to get an array of all matching elements, then loop through them and call .screenshot() on each. Make sure each screenshot uses a unique path value or you will overwrite the previous one. For element collections that load asynchronously, use page.$$eval to filter or waitForSelector to confirm the count before capturing.