{
  "$type": "site.standard.document",
  "bskyPostRef": {
    "cid": "bafyreia5inube27kcuxucdxtv7bqkq3xoa2tzlp2vmaflq6lsdinoy33nm",
    "uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3moztupsfujl2"
  },
  "coverImage": {
    "$type": "blob",
    "ref": {
      "$link": "bafkreifz6fiiftxvi2woatbwtslibee7fykmscpagcuoyen4scwjtzqlvm"
    },
    "mimeType": "image/webp",
    "size": 79496
  },
  "path": "/grabbit/how-to-take-screenshots-in-playwright-full-page-elements-ci-11gc",
  "publishedAt": "2026-06-24T11:37:41.000Z",
  "site": "https://dev.to",
  "tags": [
    "webdev",
    "javascript",
    "node",
    "programming",
    "visual regression testing guide",
    "screenshot API",
    "Puppeteer screenshot guide",
    "screenshot API comparison",
    "Grabbit blog"
  ],
  "textContent": "`page.screenshot()` does the job in two lines for a local capture or a test assertion. The friction arrives later: full-page shots that get clipped by sticky headers, Chromium installation issues in CI, and the long tail of keeping a browser fleet running in production. This guide covers the screenshot methods that work, the ones with hidden edge cases, and the point where offloading to an API makes more sense.\n\n##  The basic Playwright screenshot\n\nLaunch a browser, open a page, capture:\n\n\n\n    import { chromium } from 'playwright';\n\n    const browser = await chromium.launch();\n    const page = await browser.newPage();\n\n    await page.goto('https://example.com', { waitUntil: 'networkidle' });\n    await page.screenshot({ path: 'example.png' });\n\n    await browser.close();\n\n\nThe `waitUntil: 'networkidle'` option is worth stating explicitly. The default `load` event fires as soon as the HTML and blocking scripts are done, but client-rendered content, web fonts, and lazy-loaded images often arrive later. `networkidle` waits until there are no network connections open for at least 500ms, which is a much better proxy for \"the page actually looks right.\"\n\n##  Full-page screenshots\n\nBy default `page.screenshot()` captures the viewport only. To capture the full scrolling page, set `fullPage: true`:\n\n\n\n    await page.screenshot({ path: 'full.png', fullPage: true });\n\n\nPlaywright expands the viewport to the document's full scroll height before capturing. This works reliably on most static pages.\n\nTwo things break it:\n\n  * **Sticky and fixed-position elements.** A header with `position: fixed` renders in its normal viewport position for every \"slice\" Playwright captures, so it can appear repeated or floating over content in the final stitched image. There is no clean built-in workaround short of hiding the element with a `page.addStyleTag` before capturing.\n  * **Lazy-loaded content.** If a page only renders sections when they scroll into view, `fullPage: true` can come back short. Scroll the page yourself before capturing to trigger those renders:\n\n\n\n\n    await page.evaluate(async () => {\n      await new Promise<void>((resolve) => {\n        let scrolled = 0;\n        const step = 500;\n        const timer = setInterval(() => {\n          window.scrollBy(0, step);\n          scrolled += step;\n          if (scrolled >= document.body.scrollHeight) {\n            clearInterval(timer);\n            window.scrollTo(0, 0);\n            resolve();\n          }\n        }, 100);\n      });\n    });\n\n    await page.screenshot({ path: 'full.png', fullPage: true });\n\n\n##  Screenshotting a specific element\n\nPlaywright's locator API makes element-level captures cleaner than Puppeteer's `ElementHandle` approach. Call `.screenshot()` on any locator and Playwright crops to that element's bounding box:\n\n\n\n    // Capture a single component by CSS selector\n    await page.locator('#pricing-card').screenshot({ path: 'card.png' });\n\n    // Or by role\n    await page.getByRole('dialog').screenshot({ path: 'modal.png' });\n\n\nIf the element is below the fold, Playwright scrolls it into view before capturing. If the selector matches nothing, the call throws, so wrap it in a `waitFor` when the element might not be immediately present:\n\n\n\n    const card = page.locator('#pricing-card');\n    await card.waitFor({ state: 'visible' });\n    await card.screenshot({ path: 'card.png' });\n\n\n##  Format and quality options\n\nPlaywright defaults to PNG. For production use cases where file size matters, switch to JPEG or WebP:\n\n\n\n    await page.screenshot({\n      path: 'page.webp',\n      type: 'webp',\n      quality: 85, // 0–100, only for jpeg and webp\n    });\n\n\nPNG is lossless and best for visual regression tests where pixel-level accuracy matters. JPEG and WebP are better for images that will be served over HTTP, since they are significantly smaller at comparable quality.\n\n##  Screenshots in CI with GitHub Actions\n\nInstalling Playwright in CI requires browser binaries and their system dependencies. Use `npx playwright install --with-deps` (not just `npx playwright install`) to pull in the OS libraries Chromium needs:\n\n\n\n    - name: Install Playwright browsers\n      run: npx playwright install --with-deps chromium\n\n    - name: Run tests\n      run: npx playwright test\n\n    - name: Upload test artifacts\n      if: always()\n      uses: actions/upload-artifact@v4\n      with:\n        name: playwright-report\n        path: test-results/\n        retention-days: 7\n\n\nThe `if: always()` on the upload step is important: test artifacts are most useful when tests fail, and a plain `if: success()` would skip the upload precisely when you need the diffs.\n\nPlaywright's `--with-deps` flag is the most common CI pitfall. Without it, Chromium launches fine locally (because those libraries are already installed) but fails in a minimal CI container with a cryptic `error while loading shared libraries` message.\n\n##  Visual regression with screenshots\n\nPlaywright's built-in snapshot assertion compares a screenshot against a stored baseline:\n\n\n\n    await expect(page).toHaveScreenshot('homepage.png');\n\n\nOn the first run, Playwright writes the baseline to `__snapshots__/`. On subsequent runs it compares pixel-by-pixel and fails if the diff exceeds the configured threshold. Pass `{ maxDiffPixelRatio: 0.01 }` to tolerate minor anti-aliasing differences:\n\n\n\n    await expect(page.locator('.hero-section')).toHaveScreenshot('hero.png', {\n      maxDiffPixelRatio: 0.01,\n    });\n\n\nFor a longer look at visual regression workflows, the visual regression testing guide covers when snapshot tests make sense and how to keep them from becoming brittle.\n\n##  Where running Playwright yourself gets costly\n\nPlaywright is the right tool for test suites that already drive a browser. When screenshots are a standalone production feature, the overhead adds up:\n\n  * **Chromium in production.** Deploying a headless browser to a serverless function means bundling system libraries, increasing cold-start times, and hitting the payload size limits of most platforms.\n  * **Concurrency.** One browser instance handles one capture at a time well. Handling bursts means a pool, a queue, and back-pressure logic.\n  * **Security surface.** A browser that can render arbitrary user-submitted URLs needs SSRF protection (block private IP ranges), sandboxing, and regular security patches.\n\n\n\nThis is the point where a dedicated screenshot API is faster to operate: the browser fleet, SSRF guards, and scaling are someone else's problem.\n\n##  The same capture as an API call\n\nHere is the full-page capture above as a single request to Grabbit. No browser to provision or patch:\n\n\n\n    curl https://api.grabbit.live/v1/grabs \\\n      -H \"Authorization: Bearer sk_live_...\" \\\n      -H \"Content-Type: application/json\" \\\n      -d '{\n        \"url\": \"https://example.com\",\n        \"width\": 1280,\n        \"height\": 720,\n        \"full_page\": true,\n        \"format\": \"webp\"\n      }'\n\n\nThe response includes a hosted `image_url` you can use directly:\n\n\n\n    {\n      \"id\": \"grb_01jx...\",\n      \"status\": \"done\",\n      \"image_url\": \"https://cdn.grabbit.live/grabs/grb_01jx....webp\",\n      \"width\": 1280,\n      \"format\": \"webp\",\n      \"bytes\": 52340,\n      \"execution_ms\": 1240\n    }\n\n\nThe Playwright options you reach for most translate directly: `fullPage: true` becomes `\"full_page\": true`, the element locator pattern becomes a `\"selector\"` field, and manual wait time becomes `\"delay_ms\"` (0 to 10000). Width accepts 320 to 1920, height 240 to 1080, and `\"format\"` is `\"png\"`, `\"jpeg\"`, or `\"webp\"`.\n\n\n\n    # capture one component after waiting for it to settle\n    -d '{\n      \"url\": \"https://example.com/dashboard\",\n      \"selector\": \"#chart-container\",\n      \"delay_ms\": 800,\n      \"format\": \"png\",\n      \"width\": 1280,\n      \"height\": 720\n    }'\n\n\n##  Which to use\n\nUse Playwright directly when screenshots are part of a test suite that already controls a browser, or when you need precise assertions against local builds that are not reachable from the internet. Use an API when screenshots are a production feature and you would rather ship in an afternoon than build a browser fleet.\n\nFor a head-to-head look at Puppeteer's API versus Playwright's for captures, see the Puppeteer screenshot guide. If you are picking a hosted screenshot service, the screenshot API comparison covers the trade-offs honestly.\n\n_Originally published on the Grabbit blog._",
  "title": "How to Take Screenshots in Playwright (Full Page, Elements, CI)"
}