· Tutorials · By Simon Chiu

Puppeteer Element Screenshot: Locators, Padding, Shadow DOM

Screenshot one element in Puppeteer with locators: padding around box-shadows, shadow DOM with >>>, transparent corners, and the negative-clip bug we hit.

To screenshot one element in Puppeteer, wait for it with a locator, then call screenshot() on the handle:

const card = await page.locator('#pricing').waitHandle();
await card.screenshot({ path: 'card.png' });

Puppeteer scrolls the element into view, measures its bounding box and crops the image to it. That covers the plain case. This post covers the cases that break it: shadows that get cut off, elements inside web components, transparent corners, and a clip bug we hit while writing it. It is part of our Puppeteer screenshot guide.

Tested with: Puppeteer 25.14.0, Chrome 155.0.8059.79, Node.js 22.18.0, macOS on an Apple M1 Pro. Tested on 2026-10-18.

Locator or $?

Puppeteer gives you two ways to get an element handle:

Method Waits? Checks When to use
await page.$('#pricing') No None; returns null if absent The element is in the server HTML and you already waited for load
await page.waitForSelector('#pricing', { visible: true }) Yes Exists, optionally visible Content rendered by JavaScript
await page.locator('#pricing').waitHandle() Yes Attached, visible, stable position Anything that animates or moves into place

Locators are the newest of the three and the safest default: they retry until the element is visible and its position has stopped changing, so you do not capture a card halfway through a slide-in. A locator has no screenshot() method of its own. waitHandle() is the bridge: it runs the locator's checks and hands back an ElementHandle.

When the element never appears, set a timeout so the failure is quick and clear:

const card = await page.locator('#pricing').setTimeout(5_000).waitHandle();
// TimeoutError: Timed out after waiting 5000ms

The complete script

This script loads a small test page with setContent, so you can run it without a server. It captures four images: the plain element, the element with padding, an element inside a shadow root, and the element with transparent rounded corners.

import puppeteer from 'puppeteer';

const html = `<!doctype html><meta charset="utf-8">
<style>
  body { font: 16px system-ui; margin: 0; padding: 40px; background: #f3f4f6 }
  .card { width: 360px; padding: 24px; border-radius: 12px; background: #fff;
          box-shadow: 0 12px 32px rgba(0,0,0,.25) }
</style>
<div class="card" id="pricing"><strong>Team plan</strong><p>Shared dashboards for five people.</p></div>
<rs-badge></rs-badge>
<script>
  customElements.define('rs-badge', class extends HTMLElement {
    constructor() {
      super();
      this.attachShadow({ mode: 'open' }).innerHTML =
        '<style>.badge{display:inline-block;margin-top:24px;padding:8px 14px;border-radius:999px;background:#111;color:#fff}</style>' +
        '<span class="badge">Shadow DOM badge</span>';
    }
  });
</script>`;

const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();
  await page.setViewport({ width: 1000, height: 600, deviceScaleFactor: 2 });
  await page.setContent(html, { waitUntil: 'load' });

  // 1. Wait for the element with a locator, then screenshot its handle.
  const card = await page.locator('#pricing').waitHandle();
  await card.screenshot({ path: 'card.png' });

  // 2. The same element with padding, so the box-shadow is not cut off.
  const box = await card.boundingBox();
  const pad = 48;
  const x = Math.max(0, box.x - pad);
  const y = Math.max(0, box.y - pad);
  await page.screenshot({
    path: 'card-padded.png',
    clip: { x, y, width: box.x + box.width + pad - x, height: box.y + box.height + pad - y },
  });

  // 3. An element inside an open shadow root: >>> pierces it.
  const badge = await page.locator('rs-badge >>> .badge').waitHandle();
  await badge.screenshot({ path: 'badge.png' });

  // 4. Transparent corners: clear the page background, then omitBackground.
  await page.evaluate(() => { document.body.style.background = 'transparent'; });
  await card.screenshot({ path: 'card-transparent.png', omitBackground: true });

  await Promise.all([card.dispose(), badge.dispose()]);
} catch (err) {
  console.error(err);
  process.exitCode = 1;
} finally {
  await browser.close();
}

The four files it wrote on our machine:

File Pixels Bytes What it shows
card.png 816 × 232 12,654 The card, cropped to its border box; the shadow is cut off at the edges
card-padded.png 992 × 408 31,878 The card with its full shadow, plus the page around it (the top of the badge shows below)
badge.png 354 × 68 6,834 The badge from inside the shadow root
card-transparent.png 816 × 232 12,939 The card with see-through rounded corners

The pixel sizes are the CSS size times the device scale factor. The card is 408 × 116 CSS pixels and the viewport has deviceScaleFactor: 2, so the image is 816 × 232.

Padding around an element

An element's bounding box is its border box. Box-shadows, outlines and transforms paint outside it, so a plain element screenshot crops them. Puppeteer has no padding option for element screenshots; take a page screenshot with a larger clip instead.

How much padding: at least the shadow's blur radius plus its offset in that direction. Our card has box-shadow: 0 12px 32px, so the bottom needs 12 + 32 = 44 px. We used 48. A first try with 24 px still cut the bottom of the shadow.

The negative clip bug

Our first version of the padding code did not clamp. The card sits 40 px from the left edge and the padding was 48 px, so clip.x was −8. Puppeteer did not throw. It returned a PNG of 1,008 × 424 pixels, the size we asked for at 2×, but the content inside was drawn at 1×: the card was half size, in the top-left corner, with empty page to the right. Nothing in the output warns you.

The fix is in the listing above: clamp x and y to 0 and shrink the width and height by the same amount. If you capture elements near the right or bottom edge, clamp against the page width and height too.

Shadow DOM

A normal CSS selector stops at a shadow root. Puppeteer's query handlers add a deep combinator, >>>, that pierces open shadow roots:

// Any .badge inside the shadow root of an rs-badge host:
const badge = await page.locator('rs-badge >>> .badge').waitHandle();

// >>>> pierces exactly one level, if you need to be specific:
const inner = await page.locator('rs-badge >>>> .badge').waitHandle();

Two limits apply. Closed shadow roots (attachShadow({ mode: 'closed' })) cannot be reached by any selector; capture the host element (rs-badge) instead. Iframes are separate documents; get the frame with page.frames() or await (await page.$('iframe')).contentFrame() and query inside it.

Transparent backgrounds

omitBackground: true removes the default white page background, so areas that nothing paints become transparent. It does not remove backgrounds that your CSS sets. Our page sets background: #f3f4f6 on body, so we clear it first with document.body.style.background = 'transparent'. The card's own white background stays, and its rounded corners come out transparent.

Only PNG and WebP keep transparency. JPEG has no alpha channel, so a transparent capture saved as JPEG gets a solid background.

Element vs viewport: cost

Element screenshots are cheaper than you might expect, and smaller files. We timed card.screenshot() against page.screenshot() of the 1000 × 600 viewport, 7 times each per scale factor, and took the median:

Device scale factor Element: time Element: PNG Viewport: time Viewport: PNG
1 45 ms 5.4 KB 39 ms 17.3 KB
2 48 ms 12.0 KB 52 ms 40.9 KB
3 62 ms 18.0 KB 85 ms 68.6 KB

At 1× the element capture is a little slower because Puppeteer first measures the element and scrolls it into view. At 2× the two were within a few milliseconds of each other (a repeat run tied them), and at 3× the smaller area wins clearly. The element PNG was always less than a third of the viewport PNG. If you need a retina-quality crop of one component, an element capture at deviceScaleFactor: 2 is cheaper than a full viewport at the same scale.

Elements below the fold and taller than the viewport

elementHandle.screenshot() scrolls the element into view first (the scrollIntoView option defaults to true). An element below the fold needs no extra code.

Elements taller than the viewport also work. We captured a 2,400 px tall element in an 800 × 600 viewport and got a 2,400 px tall image, because Puppeteer captures beyond the viewport by default. If that element has lazy images, they will still be empty; see full page screenshots in Puppeteer for the scroll-and-wait pattern.

Several elements in one pass

Capture a list of components (for a design system, or one image per product card) by looping over the handles. Name files from a stable attribute rather than the loop index, so a reordered page does not swap your images:

const cards = await page.$$('[data-product-id]');
for (const card of cards) {
  const id = await card.evaluate((el) => el.dataset.productId);
  await card.screenshot({ path: `product-${id}.png` });
  await card.dispose();
}

Troubleshooting

Symptom Cause Fix
Node is either not visible or not an HTMLElement The element has display: none, zero size, or is an SVG child without a box Wait with { visible: true } or a locator; capture a visible parent
TimeoutError: Timed out after waiting 5000ms Selector never matched Check it in DevTools on the live page; look for shadow roots and iframes
Box-shadow or outline cut off Element screenshots crop to the border box Page screenshot with a padded, clamped clip
Padded image has content at the wrong scale Negative clip.x or clip.y Clamp the clip to 0
Wrong element captured Selector matched several; the first one wins Use an id or a data- attribute
Element half-rendered or mid-animation Captured during a transition Use a locator (waits for a stable position) or wait for a ready signal
Sticky header covers the top of the element Header is fixed over the scrolled element Hide it first: await page.addStyleTag({ content: 'header { position: static !important }' })
Transparent capture has a white background Page or body sets a background color, or the format is JPEG Clear the background in the page; save as PNG or WebP
Image is blurry Device scale factor is 1 setViewport({ ..., deviceScaleFactor: 2 })

The same capture with one API call

RenderScreenshot captures an element with capture.mode: "element" and a selector (see capture options). Set selector_required: true so a missing element fails with selector_not_found instead of returning a viewport image, and set page.shadow_dom: true to let the selector reach into open shadow roots. There is no padding option; to include a shadow, select a wrapper element that already contains it.

import { writeFile } from 'node:fs/promises';

const res = await fetch('https://api.renderscreenshot.com/v1/screenshot', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.RS_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    url: 'https://example.com/pricing',
    viewport: { width: 1280, height: 800, scale: 2 },
    capture: { mode: 'element', selector: '#pricing', selector_required: true },
    page: { shadow_dom: true },
  }),
});
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
await writeFile('pricing.png', Buffer.from(await res.arrayBuffer()));

With the Node.js SDK the short form is TakeOptions.url('https://example.com/pricing').element('#pricing'). Our element screenshot guide lists the selector pitfalls the API reports. Sign up for a free key to try it on your own pages.

Related in this topic