ScreenshotNeo

BlogHow-to

Playwright Screenshot Does Not Show the Full Table: How to Fix It

A full-page screenshot captures document overflow, but not necessarily rows hidden inside a nested or virtualized table. Identify the scroll container, then choose the right capture method.

By the ScreenshotNeo team4 October 202610 min read

If Playwright’s screenshot cuts off table rows, first check which element scrolls. For rows that continue below the document viewport, use await page.screenshot({ path: 'table-page.png', fullPage: true }). If the table sits inside a fixed-height scrollable wrapper, fullPage does not guarantee that the wrapper’s hidden rows appear. Scroll and capture the wrapper, or temporarily expand it for a screenshot. If the table renders only visible rows (virtualization), make the application render the rows you need or capture them in batches.

Playwright describes fullPage as capturing the full scrollable page. A locator screenshot captures the selected element; if it is a scrollable container, the screenshot shows only its currently scrolled content. See the official Page screenshot API and Locator screenshot API.

1. Diagnose what is being cut off

There are three common layouts, and they need different fixes:

What you see Likely layout Start here
Rows are below the visible browser window, and the document itself scrolls Ordinary page overflow page.screenshot({ fullPage: true })
The page stays put while a scrollbar inside the table moves Nested scroll container Scroll the wrapper and capture it in sections, or expand it for the capture
Scrolling moves rows, but earlier rows disappear and the DOM contains only a small set at a time Possibly virtualized rendering Confirm the table implementation; use app-level export/rendering or capture batches

Inspect the page and the likely wrapper before changing screenshot settings:

const info = await page.evaluate(() => {
  const candidates = [...document.querySelectorAll('table, [role="grid"], [role="table"]')];
  return candidates.map((el) => {
    const rect = el.getBoundingClientRect();
    const style = getComputedStyle(el);
    return {
      tag: el.tagName,
      role: el.getAttribute('role'),
      height: Math.round(rect.height),
      scrollHeight: el.scrollHeight,
      clientHeight: el.clientHeight,
      overflowY: style.overflowY,
      testId: el.getAttribute('data-testid'),
    };
  });
});
console.log(info);

Often the scrolling element is a parent of the table rather than the table itself. In browser DevTools, scroll the suspected element and watch whether its scrollTop changes. A positive difference between scrollHeight and clientHeight, together with scrollable overflow, is a useful clue; it does not by itself prove that all rows exist in the DOM.

2. Capture ordinary page overflow

When the table is in normal document flow and extends below the viewport, use the page screenshot with fullPage: true:

await page.goto('https://example.com/report', { waitUntil: 'networkidle' });
await page.getByRole('table', { name: 'Quarterly report' }).waitFor();
await page.screenshot({ path: 'table-page.png', fullPage: true });

Use the actual accessible table name, or replace the locator with a stable test ID. If the app continues loading data after network activity settles, wait for an app-specific completion signal, such as the final row or a loading indicator disappearing; networkidle is not a guarantee that every application has finished rendering.

Complete runnable Playwright example

This Node.js script accepts the target URL through TARGET_URL and writes a full-page screenshot. Install Playwright and its Chromium browser first with npm install playwright and npx playwright install chromium.

// save as capture-table.js
const { chromium } = require('playwright');

(async () => {
  const url = process.env.TARGET_URL || 'https://example.com';
  const browser = await chromium.launch();
  try {
    const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
    await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
    await page.screenshot({ path: 'table-page.png', fullPage: true, animations: 'disabled' });
    console.log('Saved table-page.png');
  } finally {
    await browser.close();
  }
})().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Run it with TARGET_URL=https://your-app.example/report node capture-table.js. For authenticated pages, create a browser context with the necessary storage state or sign in in the script before navigating to the report.

3. Capture a table inside its own scrollable wrapper

A nested scroller has its own scroll position. Calling locator.screenshot() on it captures the currently visible content within that element; it does not turn all hidden rows into one tall image. You have two practical approaches.

Option A: expand the wrapper for a single image

If the page is your application or you can safely apply screenshot-only styling, remove the wrapper’s height limit and allow its contents to flow. Select the wrapper using a stable test ID or another specific selector. The CSS below is an example only: adapt it to the application’s actual layout.

const wrapper = page.getByTestId('results-scroll');
await wrapper.evaluate((el) => {
  el.dataset.screenshotOriginalStyle = el.getAttribute('style') || '';
  el.style.setProperty('height', 'auto', 'important');
  el.style.setProperty('max-height', 'none', 'important');
  el.style.setProperty('overflow', 'visible', 'important');
});
await page.screenshot({ path: 'expanded-table.png', fullPage: true });

This can change column widths, sticky headers, sticky columns, or other positioning. Compare the screenshot with the original layout. A sticky header may repeat, overlap, or appear at an unexpected position after the wrapper changes. For a locator-only image, use await wrapper.screenshot({ path: 'expanded-table.png' }) after the expansion, but check that the element’s new bounds include the full content.

Option B: scroll and save viewport-sized sections

When changing layout would make the image inaccurate, scroll the wrapper and save a series of captures. This complete example writes one PNG per scroll position. The small overlap helps preserve context between neighboring sections.

const fs = require('node:fs/promises');
const { chromium } = require('playwright');

(async () => {
  const url = process.env.TARGET_URL || 'https://example.com';
  const browser = await chromium.launch();
  try {
    const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
    await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
    const scroller = page.getByTestId('results-scroll');
    await scroller.waitFor({ state: 'visible' });

    const metrics = await scroller.evaluate((el) => ({
      scrollHeight: el.scrollHeight,
      clientHeight: el.clientHeight,
    }));
    const step = Math.max(1, metrics.clientHeight - 80);
    let index = 0;
    for (let top = 0; top < metrics.scrollHeight; top += step) {
      await scroller.evaluate((el, y) => { el.scrollTop = y; }, top);
      // Allow scroll-triggered rendering and lazy content a moment to update.
      await page.waitForTimeout(150);
      await scroller.screenshot({ path: `table-part-${String(index).padStart(3, '0')}.png` });
      index += 1;
    }
    console.log(`Saved ${index} table sections`);
  } finally {
    await browser.close();
  }
})().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Install and run as in the previous example. Replace results-scroll with a test ID present in your application. The loop uses the initial scroll height; if scrolling loads more records and changes that height, re-read scrollHeight while scrolling and stop only when the application reports that all rows are loaded or no further height/data changes occur. For very long tables, set a maximum number of sections to avoid an accidental unbounded capture.

4. Check for virtualized rows

Some grids render only the rows near the viewport and recycle their DOM nodes as you scroll. In that case, a taller screenshot or a larger clip cannot include rows that were never rendered at the same time. This is a possible cause, not something that can be diagnosed without knowing the application and table library.

  1. Inspect the DOM at the top and after scrolling. If row elements are replaced or the DOM row count stays small while the displayed row range changes, virtualization may be in use.
  2. Check the grid library’s configuration for an export or render-all option. Prefer the application’s complete data export when the goal is a record of every row.
  3. If visual evidence is needed, capture scroll positions in batches, waiting for the visible row content to update each time. Avoid assuming that a fixed delay is sufficient; wait on a row label, range indicator, or other application-specific signal.
  4. For screenshot tests, consider a test fixture with a small non-virtualized dataset, if the behavior under test does not depend on virtualization.

5. Use clipping only to crop visible content

The Page screenshot clip option accepts x, y, width, and height coordinates. It selects an image region; it does not scroll a nested container or cause unrendered rows to appear.

await page.screenshot({
  path: 'table-crop.png',
  clip: { x: 120, y: 180, width: 1100, height: 700 },
});

Use clipping when the rows you want are already in the captured page area and you need to exclude surrounding content. For a locator image, await page.getByTestId('results-scroll').screenshot({ path: 'table.png' }) targets the element’s bounds, but a scrollable element still shows the current scrolled content.

6. Make captures deterministic

  • Wait for the right state: wait for the table, a known row, or the app’s loading indicator to signal completion. A successful navigation does not mean asynchronous table data is ready.
  • Choose stable locators: prefer accessible roles and names or an explicit test ID over brittle structural CSS. Playwright recommends user-facing locators and explicit contracts in its locator guidance.
  • Control viewport and scale: use a fixed viewport for repeatable dimensions. A full-page image may be very tall; confirm the output dimensions suit the downstream viewer.
  • Disable animation when appropriate: animations: 'disabled' can reduce transient states in screenshots. It does not wait for your data or guarantee a stable app state.
  • Mind fixed and sticky elements: sticky headers, fixed footers, and overlays can cover rows or produce unexpected results during full-page capture and scrolling.
  • Do not silently hide meaningful rows: removing a scrollbar or changing overflow affects presentation. Keep screenshot-only CSS scoped to the capture and verify that it does not hide, reorder, or truncate content.

7. Troubleshooting

Symptom Cause to check Fix
fullPage: true still shows only some rows The table has a nested scrollable wrapper Scroll and capture the wrapper, or expand it with application-specific screenshot styling.
The screenshot contains only the rows currently visible in the wrapper A locator screenshot preserves the scroller’s current position Scroll before each capture, or expand the wrapper and capture again.
Later rows are absent even after making the page taller The rows may not exist in the DOM yet, or the app may virtualize them Check the rendered row range; use an app export/render-all mechanism or capture batches after each update.
Screenshot is taken before rows appear Navigation completed before client-side data loading or rendering Wait for a meaningful app-specific row or loading-state condition before capture.
Capture loop repeats the same rows The wrong element is being scrolled, or application rendering has not caught up Verify the wrapper’s scrollTop changes; wait for a visible row marker to change before saving.
Rows or columns overlap after expanding Sticky positioning or fixed dimensions depend on the original wrapper Adjust screenshot CSS for that app, disable the relevant sticky rule only for capture, or save separate sections.
Locator times out or matches multiple elements The selector is absent, unstable, or ambiguous Use an accessible role/name or unique test ID, and verify the target is present on the loaded page.
Image is too large or capture is slow A very long page or oversized table is being rasterized in one image Capture sections, reduce unnecessary viewport width, or export data instead when a visual image is not required.

8. Performance, reliability, and cost

A full-page image grows with the page’s dimensions, so tall reports consume more memory and take longer to render and write than viewport captures. Nested scrolling adds browser work for each section; use a sensible overlap and avoid capturing duplicate areas. If the target is a data record rather than a visual snapshot, an application export is usually a better fit than one enormous raster image.

For repeatable results, use the same browser engine, viewport, device scale factor, authentication state, and app data in each run. Wait for table readiness instead of relying only on a fixed sleep. A screenshot can faithfully capture an incomplete or transient page, so a successful screenshot call alone does not prove the table is complete.

Self-hosted Playwright has no per-image ScreenshotNeo charge, but your runs use your own compute and browser infrastructure. If you need an API that returns images or PDFs, ScreenshotNeo offers one-call capture; its listed plans include 1,000 free shots per month and paid plans from $5 for 3,000 shots. Its stated billing rule excludes bot checks/CAPTCHAs, blank pages, timeouts, failed loads, and cache hits; inspect the response’s verdict and billing headers. See the ScreenshotNeo API documentation for supported parameters and response details.

Or skip the browser setup

ScreenshotNeo accepts one GET request and returns an image or PDF. This example requests a WebP screenshot; see the API documentation for the full parameter list and options.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

It removes cookie banners, newsletter popups, and chat widgets before the shot. Bot checks, blank pages, and failed loads are never billed. Its MCP server gives AI agents tools to take screenshots. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000. Sign up for free and get 1,000 screenshots a month with no card.

FAQ

Does fullPage: true scroll every element on the page?

No. It captures the page’s scrollable extent. A nested table scroller has its own scroll position and needs its own handling.

Can clip reveal rows that are out of view?

No. Clip defines the screenshot region; it does not scroll content or render rows that are absent.

Should I combine the sections into one image?

Only if a single long image is useful for the next step. Separate sections are often easier to inspect and avoid building an extremely tall bitmap; preserve enough overlap to orient adjacent captures.

Will Playwright always capture a virtualized table in full?

No. If the application renders only a moving window of rows, use the app’s export or render strategy, or capture batches as the window moves.

Sources