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
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
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') 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:
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:
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
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: 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:
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
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). 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
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 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:
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). Keep Playwright for pages behind a login, internal apps, visual regression tests in CI and anything needing more than clicks. Language guides: Python, JavaScript, PHP, Ruby.