Playwright Screenshot of a Virtualized Table With All Rows Rendered
Playwright full-page screenshots do not render rows omitted by table virtualization. Render all rows in test mode or scroll, capture, and verify each segment.
Direct answer: Playwright’s fullPage: true captures the full scrollable page, but it does not make a virtualized table render rows that the application has left out of the DOM. To capture every logical row, either use an application-supported test mode that renders all rows, or scroll the table’s actual container, wait for each new row range to render, and save overlapping viewport screenshots. Verify that the captured row indices cover the expected range before calling the result complete.
The right method depends on the table component and application. There is no universal Playwright switch that disables virtualization. This guide uses TypeScript with Playwright; the same workflow applies to other Playwright languages, with application-specific selectors and readiness signals.
1. Understand what Playwright captures
Playwright documents a full-page screenshot as a capture of the full scrollable page, as if it were shown on a very tall screen. This changes the screenshot extent; it does not instruct a table library to create every row. A virtualized table typically keeps only a window of rows in the DOM and replaces that window as you scroll.
An element screenshot has a related limitation: for a scrollable container, the screenshot shows only the content currently scrolled into view. See the Locator screenshot documentation. If the table scrolls inside a fixed-height element, taking a screenshot of that element once will not reveal its other scroll positions.
| Method | What it can capture | Use it when |
|---|---|---|
page.screenshot({ fullPage: true }) |
The full scrollable page as laid out by the application; only rows the application rendered are available. | The content is not virtualized, or the application has already rendered every row. |
| Table locator screenshot | The table element’s current visible content, including its current scroll position. | You need one viewport-sized image or a segment in a scroll-and-capture workflow. |
| Test mode with virtualization disabled | Potentially every row in one tall table, if the application/component supports that mode. | You control the app and can preserve representative styling while enabling all-row rendering. |
| Incremental scroll and capture | A sequence of viewport images covering successive logical row ranges. | The table must remain virtualized or rendering all rows is impractical. |
2. Choose a capture strategy
Prefer an application-supported all-rows test mode when practical
If you own the application, check whether the table component or app has a supported test configuration that disables row virtualization or renders all rows. Prefer a documented component option or a test-only application hook. Keep normal production behavior unchanged outside the test configuration. After rendering, verify the first and last logical rows and the total expected row count.
A single image is convenient for review, but rendering thousands of rows can create a very tall image, increase browser memory use, and make text hard to read at normal display size. The all-rows mode can also change layout behavior or style if it bypasses the production rendering path. Confirm that the captured result still represents the intended UI.
Use viewport segments when all-row rendering is unavailable
Find the element that actually scrolls. It may be an inner grid viewport, not the page. Scroll that element in increments smaller than its client height so neighboring captures overlap. After each scroll, wait for an app-specific signal that the new logical row range has rendered and settled. Capture the table viewport and record the logical indices visible in the image.
Do not use a fixed pause as proof of readiness. A short pause can happen to work locally and fail when data, rendering, or CI machines are slower. Prefer a component callback, a stable loading indicator, an expected row index, or another signal tied to the table’s state.
3. Runnable TypeScript example: scroll and capture row ranges
The following script is runnable after installing Playwright. It assumes the page exposes a scroll container with data-testid="orders-viewport", each rendered logical row has data-row-index, and the app exposes the expected row count as data-testid="orders-total". Replace these with selectors and attributes from your own app. The code validates coverage and fails if it sees a gap.
npm install -D playwright
npx playwright install chromium
Save as capture-table.ts and run it with your TypeScript runner, or compile it using your project’s TypeScript setup.
import { chromium, type Locator, type Page } from 'playwright';
import fs from 'node:fs/promises';
const target = process.env.TARGET_URL ?? 'http://localhost:3000/orders';
const outputDir = process.env.OUTPUT_DIR ?? 'table-shots';
const viewportSelector = '[data-testid="orders-viewport"]';
const rowSelector = '[data-row-index]';
async function readVisibleIndices(viewport: Locator): Promise {
return viewport.locator(rowSelector).evaluateAll(rows =>
rows.map(row => Number(row.getAttribute('data-row-index')))
.filter(Number.isInteger)
);
}
async function waitForRangeChange(
viewport: Locator,
previous: number[],
timeoutMs = 10_000
): Promise<number[]> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const current = await readVisibleIndices(viewport);
if (current.length > 0 && current.join(',') !== previous.join(',')) {
return current;
}
await new Promise(resolve => setTimeout(resolve, 50));
}
throw new Error(`Timed out waiting for rendered row range to change; previous=${previous}`);
}
async function capture(page: Page) {
await fs.mkdir(outputDir, { recursive: true });
const viewport = page.locator(viewportSelector);
await viewport.waitFor({ state: 'visible' });
// This example reads an app-provided total. Replace this with a reliable
// expected count from your test fixture or application state.
const total = Number(await page.getByTestId('orders-total').getAttribute('data-count'));
if (!Number.isInteger(total) || total < 1) {
throw new Error(`Invalid expected row count: ${total}`);
}
const seen = new Set<number>();
let previous: number[] = [];
let segment = 0;
while (true) {
const current = await readVisibleIndices(viewport);
if (current.length === 0) throw new Error('No indexed rows are currently rendered');
current.forEach(index => seen.add(index));
await viewport.screenshot({ path: `${outputDir}/segment-${String(segment).padStart(4, '0')}.png` });
const last = Math.max(...current);
if (last >= total - 1) break;
previous = current;
const metrics = await viewport.evaluate((element, overlap) => {
const el = element as HTMLElement;
const step = Math.max(1, el.clientHeight - overlap);
const before = el.scrollTop;
el.scrollTop = Math.min(el.scrollTop + step, el.scrollHeight - el.clientHeight);
return { before, after: el.scrollTop, height: el.clientHeight };
}, 80);
if (metrics.after === metrics.before) {
throw new Error(`Scroll position did not advance at segment ${segment}`);
}
// Wait on the table's rendered logical range, not on elapsed time alone.
const next = await waitForRangeChange(viewport, previous);
next.forEach(index => seen.add(index));
segment += 1;
}
const missing: number[] = [];
for (let index = 0; index < total; index += 1) {
if (!seen.has(index)) missing.push(index);
}
if (missing.length) {
throw new Error(`Incomplete capture: ${missing.length} row indices missing; first missing: ${missing.slice(0, 20)}`);
}
console.log(`Saved ${segment + 1} segments covering ${total} logical rows to ${outputDir}`);
}
const browser = await chromium.launch();
try {
const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
await page.goto(target, { waitUntil: 'domcontentloaded' });
// Replace this with an application-specific ready signal when available.
await page.getByTestId('orders-viewport').waitFor({ state: 'visible' });
await capture(page);
} finally {
await browser.close();
}
This sample requires that data-row-index describe the logical row represented by each element. Some virtualizers recycle a fixed set of DOM elements; a DOM position or slot number is not necessarily a logical row index. If the app does not expose logical indices, add a test hook or query component state through an app-supported interface. Also adapt the wait condition if the visible range can remain unchanged during a scroll because of overscan or small movement.
4. Make row coverage trustworthy
- Establish the expected range. Get the row count from a stable test fixture, an application-provided count, or the same source used to populate the table. Do not infer completion merely because scrolling stopped.
- Record logical indices. For each image, record the first and last logical row indices visible. If the table exposes ARIA row indices, confirm whether they are one-based and whether headers affect the numbering.
- Overlap captures. Leave enough shared content between adjacent images to make stitching and visual review possible. The sample uses an 80 CSS-pixel overlap; choose a value that includes at least part of a row in your layout.
- Check endpoints and gaps. Confirm that the first and final expected rows appear and that the union of captured indices covers the entire expected set. A screenshot call succeeding only means Playwright produced an image.
- Handle data changes. Freeze or seed the underlying data during capture. Sorting, live updates, pagination, or inserts while scrolling can shift indices and create duplicates or omissions even if the scroll loop works correctly.
- Keep a manifest. Save each segment’s filename and observed index range alongside the images. This makes gaps diagnosable and gives a later stitching step explicit ordering information.
5. Optional all-rows screenshot
If the application has a supported test mode that renders all rows, the capture itself is simple. The example below assumes your app’s test configuration has already disabled virtualization and rendered every row. It checks the row count before taking the full-page screenshot.
import { chromium } from 'playwright';
const browser = await chromium.launch();
try {
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
await page.goto('http://localhost:3000/orders?testRenderAll=true', {
waitUntil: 'domcontentloaded'
});
const expected = 500; // Set this from your fixture or app configuration.
await page.getByTestId('orders-table').waitFor({ state: 'visible' });
const rendered = await page.locator('[data-row-index]').count();
if (rendered !== expected) {
throw new Error(`Expected ${expected} rendered rows; found ${rendered}`);
}
await page.screenshot({ path: 'all-rows.png', fullPage: true });
} finally {
await browser.close();
}
The query parameter above is illustrative: implement only a test switch your own application supports. The assertion assumes one matching element per logical row; adjust it if the component also renders group rows, headers, or other indexed elements. A very tall screenshot may be awkward to open, inspect, or compare. Consider segment images when image dimensions or memory become a problem.
6. Other Playwright languages and API calls
The central constraint is the same in every language: set fullPage for page extent only when it matches your rendered DOM; otherwise scroll the correct container and capture each rendered range. In Java, Python, and .NET, use the corresponding Page or Locator screenshot API and the app-specific readiness logic for your table.
For a regular, non-virtualized page, this is the standard Playwright full-page call:
await page.screenshot({ path: 'page.png', fullPage: true });
For one current table viewport:
await page.locator('[data-testid="orders-viewport"]').screenshot({ path: 'visible-table.png' });
Neither call traverses virtualized row windows on its own. Playwright documents scrolling controls such as locator.evaluate() and mouse wheel input in its scrolling guide; use the mechanism that corresponds to the table’s actual scroll container.
7. Capture and stitching trade-offs
| Consideration | Render all rows | Capture segments |
|---|---|---|
| Logical completeness | Easy to assert in one DOM state if the test mode truly renders all rows. | Must verify the union of logical indices and account for data changing between scrolls. |
| Image size and readability | Can create an extremely tall image that scales down poorly. | Images stay manageable; readers can inspect each segment at native size. |
| Production UI fidelity | May differ if the test mode changes component behavior or styling. | Uses the virtualized UI, but captures it at multiple scroll positions. |
| Sticky headers | Usually appear according to the all-row layout and CSS behavior. | May be repeated in every segment; keep, crop, or mask them deliberately if stitching. |
| Runtime and memory | Rendering many DOM rows at once can use more browser resources. | Adds scroll, render-wait, and image-write steps; segment size bounds each image. |
| Visual continuity | One image has no segment seams, though extreme height affects usability. | Overlap helps align segments, but shadows, sticky content, and changing data can complicate stitching. |
Playwright does not provide a universal virtual-table stitching API. If you stitch images, retain the originals and decide how to treat overlap, duplicated sticky headers, borders, and row backgrounds. For visual regression, comparing segments independently is often easier to diagnose than comparing one enormous composite.
8. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Full-page image contains only a few rows | The table virtualizes rows, so the rest were not in the DOM. | Use an app-supported all-rows test mode or scroll and capture successive ranges. |
| Element screenshot shows only the current rows | The locator points to a scrollable container, whose current scrolled content is what gets captured. | Capture after each scroll position; verify the scroll target is the table viewport. |
| The scroll loop captures the same rows repeatedly | The selector targets the wrong element, scroll is intercepted, or row-range detection is measuring recycled slots. | Inspect scrollTop, scrollHeight, and clientHeight; identify the real scroller and use logical row identifiers. |
| The loop times out waiting for a new range | The app may not have rendered yet, the requested movement was within overscan, or the range signal is not tied to logical rows. | Wait for the component’s loading/render signal or an expected index; increase movement while preserving overlap and validate the actual range. |
| Rows are missing despite successful screenshots | The loop ended at the scroll limit, async data had not arrived, indices were misread, or rows changed during capture. | Assert expected count, first and last indices, and no gaps; freeze data and wait for a reliable application signal. |
| Top or bottom rows are clipped | Scroll increments or screenshot bounds cut through a row, or sticky elements obscure content. | Use overlap, capture the table viewport consistently, and inspect the first and final segment at native size. |
| Capture is slow or the browser runs out of memory | Too many rows are rendered at once, images are too large, or the capture uses a high device scale factor. | Use viewport segments, reduce viewport or device scale where acceptable, and avoid retaining unnecessary image buffers. |
| Visual comparisons change between runs | Fonts, animations, live data, time-dependent cells, or load timing vary. | Stabilize fixtures and fonts, disable or await animations where appropriate, and wait for app-specific readiness before capture. |
9. Performance, reliability, and cost
There is no universal fastest strategy: the result depends on row count, component implementation, browser, and whether data loads during scrolling. All-row rendering reduces the number of capture operations but can increase DOM size and memory use. Segments bound each image’s dimensions and make missing ranges easier to identify, at the cost of repeated scrolling, readiness checks, and file writes. Measure in the environment that matters to your workflow rather than assuming a fixed speed.
For reliable automation, use deterministic fixture data, a stable viewport and device scale factor, a bounded timeout for app readiness, and explicit coverage assertions. Keep screenshots and a row-range manifest as artifacts when a capture fails. If the table is changing or paginated, coordinate with the app or test fixture so the expected dataset remains stable for the whole capture.
Local Playwright captures have no per-screenshot service charge, but use compute time and storage in the environment where they run. Consider CI runtime, artifact size, and browser memory when choosing image dimensions and segment size.
10. Or skip the browser setup
If you need a clean screenshot of the page rather than proof that every virtualized data row was enumerated, ScreenshotNeo is a website screenshot API and MCP server. Its one-call API can capture a URL, but it does not replace row-index coverage checks when your requirement is evidence that every logical row appeared.
cURL:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
Python:
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)
Node.js:
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
See the ScreenshotNeo API documentation for request options. Cookie banners, popups, and chat widgets are removed before the shot; bot checks, blank pages, and failed loads are never billed; an MCP server lets AI agents take screenshots; and 1,000 screenshots a month are free with no card, with paid plans starting at $5 for 3,000. Sign up for 1,000 free screenshots a month.
FAQ
Does fullPage: true force a virtualized table to render every row?
No. It captures the full scrollable page produced by the application. It does not define a universal way to disable a table’s virtualization.
Can I take one screenshot of a virtualized table element?
That captures the element at its current scroll position. To cover other positions, scroll and capture additional segments, or use an application-supported mode that renders all rows.
How do I know the screenshot includes every row?
Compare observed logical row indices with the expected range, including the first and final rows, and check for gaps. A completed screenshot operation alone does not establish data completeness.
Should I stitch the segments into one image?
Only if a single composite helps the intended review. Preserve the source segments and account for overlap, sticky headers, readability, and very large output dimensions.


