# Playwright screenshots in Node and Python: full page, locator, mask

> Tested Playwright screenshot code in Node and Python: fullPage, locator.screenshot, mask, animations, scale css, waiting, devices and Docker.

Published 2026-11-04 · https://shareglint.com/en/guides/playwright-screenshot

A Playwright screenshot is `await page.screenshot({ path: 'shot.png' })` in Node or `page.screenshot(path="shot.png")` in Python. `fullPage` / `full_page` captures the whole scrollable page, `locator.screenshot()` one element, and three options make captures repeatable: `animations: 'disabled'`, `mask` (cover dynamic regions) and `style` (CSS applied only during the capture). `scale: 'css'` keeps high-DPI captures at one pixel per CSS pixel. Everything below ran with Playwright 1.64.1, Node and Python, in the official `mcr.microsoft.com/playwright` image.

## Node: full page, element, retina

```js
import { chromium } from 'playwright';

const browser = await chromium.launch();
try {
  const context = await browser.newContext({
    viewport: { width: 1280, height: 800 },
    deviceScaleFactor: 2,
    colorScheme: 'dark',
    reducedMotion: 'reduce',
  });
  const page = await context.newPage();
  await page.goto('https://example.com', { waitUntil: 'load' });

  await page.screenshot({ path: 'retina.png' });                                     // 2560×1600
  await page.screenshot({ path: 'full.png', fullPage: true, animations: 'disabled', scale: 'css' }); // 1280 wide
  await page.locator('#pricing').screenshot({ path: 'pricing.png' });
} finally {
  await browser.close();
}
```

Viewport, pixel ratio, colour scheme and reduced motion are context options in Playwright, not page calls. The `scale` option decides what the pixel ratio does to the file: `'device'` (the default for `page.screenshot`) gives one pixel per device pixel — 2560×1600 above — and `'css'` one pixel per CSS pixel, 1280×800. `locator.screenshot()` waits for the element to be visible and stable, scrolls it into view and clips the page to its box — so a fixed cookie banner lying over the element is in the picture too. If the locator matches several elements it fails in strict mode; narrow it (`.first()`) when needed.

## Python: sync and async

```python
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    context = browser.new_context(**p.devices["Pixel 7"])
    page = context.new_page()
    page.goto("https://example.com", wait_until="domcontentloaded")
    page.locator("main").first.wait_for(state="visible")
    page.screenshot(path="full.png", full_page=True, animations="disabled", scale="css")
    page.locator("#pricing").screenshot(path="pricing.png")
    browser.close()
```

Options are snake_case (`full_page`, `wait_until`, `device_scale_factor`); values are the same strings. In an asyncio app use `playwright.async_api` with `async with async_playwright() as p:` and `await` on each call — the sync API refuses to run inside a running event loop. `p.devices["Pixel 7"]` gave a 412×839 viewport at pixel ratio 2.625 in our run.

## Waiting: load states and locators

`page.goto` resolves on `load` by default. [`waitForLoadState('networkidle')`](https://playwright.dev/docs/api/class-page#page-wait-for-load-state) waits for 500 ms without network connections, and the docs mark it **discouraged** — pages with analytics beacons or long polling never go quiet, and quiet does not mean rendered. Wait for what you need in the picture:

```js
await page.locator('.chart svg').waitFor({ state: 'visible' });
await page.evaluate(() => document.fonts.ready);
```

`page.waitForTimeout()` still exists in Playwright (unlike Puppeteer, which removed it), but its own documentation says to use it only for debugging. Lazy images need a scroll-through before a full-page capture, exactly as in Puppeteer:

```js
await page.evaluate(async () => {
  for (let y = 0; y < document.body.scrollHeight; y += window.innerHeight) {
    window.scrollTo(0, y);
    await new Promise((resolve) => setTimeout(resolve, 250));
  }
  window.scrollTo(0, 0);
});
```

## Stable screenshots: animations, mask, style

```js
await page.screenshot({
  path: 'stable.png',
  fullPage: true,
  animations: 'disabled',
  mask: [page.locator('.ad-slot'), page.locator('time')],
  maskColor: '#e5e7eb',
  style: `
    #onetrust-banner-sdk, #CybotCookiebotDialog, .chat-launcher { display: none !important; }
    .site-header { position: absolute !important; }
  `,
});
```

From the [screenshot options](https://playwright.dev/docs/api/class-page#page-screenshot): `animations: 'disabled'` fast-forwards finite CSS animations and transitions to their end and resets infinite ones (a 10-second fade-in was at opacity 1 in our capture). `mask` paints a box (pink `#FF00FF` unless `maskColor` says otherwise) over each matched element — it covers, it does not remove, so the layout stays the same. `style` is a stylesheet applied only while the screenshot is taken; it pierces shadow DOM and applies inside frames, which makes it the cleanest place to hide cookie banners and chat widgets. `caret` defaults to `'hide'`.

Hiding a consent dialog can leave a scroll lock or a dimmed overlay behind. Clicking accept first is more robust where the button exists:

```js
const accept = page.getByRole('button', { name: /accept all/i });
if (await accept.isVisible()) await accept.click();
```

## Full page, 100vh and fixed headers

In our test, a `height: 100vh` hero on a 600 px viewport stayed 600 px tall in a `fullPage` capture, and a `position: fixed` header appeared once, at the top — Playwright captures beyond the viewport rather than resizing it. Headers that JavaScript changes on scroll are captured in their last state, so scroll back to the top, or pin them with `style` as above.

## Device descriptors

```js
import { chromium, devices } from 'playwright';

const context = await browser.newContext({ ...devices['iPhone 15'] });
```

A descriptor sets the user agent, viewport, `deviceScaleFactor`, `isMobile` and `hasTouch` ([emulation docs](https://playwright.dev/docs/emulation)). `devices['iPhone 15']` is 393×659 at pixel ratio 3 and names WebKit as its default browser; spread into a Chromium context it still works — Chromium emulates the metrics and user agent, but it is not Safari's engine. Use `browser = await webkit.launch()` when Safari rendering is the point.

## Running it in Docker

```bash
docker run --init --ipc=host -v "$PWD":/work -w /work \
  mcr.microsoft.com/playwright:v1.64.1-noble node shot.mjs
# Python: mcr.microsoft.com/playwright/python:v1.64.1-noble
```

The [Playwright Docker docs](https://playwright.dev/docs/docker) recommend `--ipc=host` because without it "Chromium can run out of memory and crash", and `--init` to reap processes. Pin the image to the same version as your `playwright` package: a mismatch looks for browser builds that are not there. The image runs as root, which disables Chromium's sandbox; for crawling untrusted sites the docs suggest the `pwuser` user plus their seccomp profile.

The rest is the same bill as any self-hosted browser: memory per concurrent page, timeouts, a queue, browser updates with every Playwright release, missing fonts (emoji, CJK) in slim images, and server-side request forgery when users choose the URL — a redirect to `169.254.169.254` or `10.x` reaches internal services through your browser unless an egress filter checks addresses after DNS resolution.

## When an API is the low-ops option

For screenshots of public pages as a feature of your app, the same capture is one HTTP request to a [screenshot API](https://shareglint.com/en/screenshot-api):

```python
import os, requests

r = requests.post(
    "https://shareglint.com/v1/screenshots",
    params={"wait": "true"},
    headers={"Authorization": f"Bearer {os.environ['SHOT_API_KEY']}"},
    json={
        "url": "https://example.com",
        "full_page": True,
        "lazy_load": True,
        "device": "pixel-8",
        "block_cookie_banners": True,
        "hide_selectors": [".chat-launcher"],
        "wait_for": ".chart svg",
        "reduced_motion": True,
    },
    timeout=(5, 60),
)
print(r.json()["url"])
```

`selector` replaces `locator.screenshot()`, `hide_selectors` the `style` trick, `lazy_load` the scroll loop, and the browsers, banner blocking and egress filtering are not yours to run. A viewport capture uses 1 render, a full page 2, repeats within 24 hours are free ([pricing](https://shareglint.com/en/pricing)). Keep Playwright for pages behind a login, internal apps, visual regression tests in CI and anything needing more than clicks. Language guides: [Python](https://shareglint.com/en/screenshot-api/python), [JavaScript](https://shareglint.com/en/screenshot-api/javascript), [PHP](https://shareglint.com/en/screenshot-api/php), [Ruby](https://shareglint.com/en/screenshot-api/ruby).

## FAQ

### How do I take a full-page screenshot in Playwright?

page.screenshot({ path: 'full.png', fullPage: true }) in Node, or page.screenshot(path="full.png", full_page=True) in Python. Scroll through the page first if it lazy-loads images.

### How do I screenshot a single element in Playwright?

Call screenshot() on a locator: await page.locator('#pricing').screenshot({ path: 'pricing.png' }) in Node, page.locator("#pricing").screenshot(path="pricing.png") in Python. It waits for the element to be visible and stable and scrolls it into view first.

### Should I use waitUntil networkidle in Playwright?

The Playwright docs mark networkidle as discouraged. Prefer waiting for the element you want in the picture (locator.wait_for or an expect assertion); networkidle can still help as an extra signal on pages without long-polling or beacons.

### How do I make Playwright screenshots stable between runs?

Pass animations: 'disabled' (finite animations jump to their end, infinite ones reset), mask dynamic regions such as timestamps and ads, hide elements with the style option, and pin the viewport, deviceScaleFactor and fonts.
