How to screenshot a web app with a virtualized React list
A full-page screenshot may miss rows that a virtualized list has not rendered. Use Playwright to capture the visible window, scroll through every row, or add a capture mode.
A full-page screenshot captures the page’s scrollable extent; it does not necessarily make a virtualized React list render every row. Virtualization, also called windowing, keeps only a small set of rows in the DOM and moves that window as the user scrolls. To capture every row, scroll the list’s actual container through the needed ranges, wait for each region to render and load, then capture successive viewport or list-element images. Stitch them if you need one tall image.
Playwright’s documentation defines a full-page screenshot as “a screenshot of a full scrollable page, as if you had a very tall screen and the page could fit it entirely.” That describes page geometry, not whether scroll-dependent components have rendered their off-screen content. Playwright Screenshots · Playwright Page API.
1. Choose the capture method
| Goal | Approach | Trade-off |
|---|---|---|
| Capture what a user sees now | Viewport screenshot | Only the current window |
| Capture ordinary page content | Full-page screenshot | May omit virtualized rows outside the active window |
| Capture all rows while keeping virtualization active | Scroll the list and capture each region | Needs app-specific scrolling, readiness checks, and possibly stitching |
| Produce one tall image with control of the app | Add a capture-only mode that renders all required rows | Requires app changes and can differ from the interactive view |
For a single visible state, use page.screenshot(). For normal full-document content, use fullPage: true. For every row in a virtualized list, use repeated real scrolls or an app-specific capture mode. A simple full-page capture is not a reliable all-rows solution when rows only enter the DOM during scrolling.
2. Set up a Playwright capture
The following Node.js example uses Playwright’s library API. Install it in your project with npm install playwright; install the browser for your chosen project setup as described in the Playwright installation guide. Replace the example URL and selectors with those from your app.
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
try {
await page.goto('http://localhost:3000', { waitUntil: 'domcontentloaded' });
// Replace this with the element that actually scrolls the list.
const list = page.locator('[data-testid="virtual-list"]');
await list.waitFor({ state: 'visible' });
// Capture the current viewport only.
await page.screenshot({ path: 'current-view.png' });
// This captures page extent, but does not guarantee every virtualized row.
await page.screenshot({ path: 'page-extent.png', fullPage: true });
} finally {
await browser.close();
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
The selectors and readiness conditions are app-specific. Check the Playwright API for the version installed in your project; screenshot options may vary by version.
3. Capture every row by scrolling the list
Keep the list’s normal virtualized behavior active, scroll its own container in increments, and capture each visible state. This example saves numbered viewport screenshots. It measures the list’s current scroll height, waits for rows to appear after each move, and stops when it reaches the end.
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
try {
await page.goto('http://localhost:3000', { waitUntil: 'domcontentloaded' });
const list = page.locator('[data-testid="virtual-list"]');
await list.waitFor({ state: 'visible' });
// Adapt these to a stable row selector and your app's initial loading state.
await page.locator('[data-row-index]').first().waitFor({ state: 'visible' });
const metrics = await list.evaluate((el) => ({
height: el.clientHeight,
scrollHeight: el.scrollHeight,
}));
const step = Math.max(1, metrics.height - 80); // 80px overlap helps avoid gaps.
let y = 0;
let part = 0;
while (true) {
await list.evaluate((el, top) => { el.scrollTop = top; }, y);
// Replace with a condition that means this region's data and rows are ready.
await page.waitForFunction(
({ selector, top }) => {
const el = document.querySelector(selector);
return el && Math.abs(el.scrollTop - top) <= 1 && el.querySelector('[data-row-index]');
},
{ selector: '[data-testid="virtual-list"]', top: y },
);
await page.screenshot({ path: `part-${String(part).padStart(4, '0')}.png` });
const position = await list.evaluate((el) => ({
top: el.scrollTop,
height: el.clientHeight,
scrollHeight: el.scrollHeight,
}));
if (position.top + position.height >= position.scrollHeight - 1) break;
const nextY = Math.min(position.top + step, position.scrollHeight - position.height);
if (nextY <= y) break; // Prevent an infinite loop if geometry stops changing.
y = nextY;
part += 1;
}
} finally {
await browser.close();
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
This is a starting pattern, not a universal drop-in script. If the page scrolls instead of the list element, scroll the page and measure its document. If the list is nested, use the selector for the specific scrolling ancestor. If the list changes height as data arrives, re-read its dimensions after each load.
Wait for the right thing
A timeout alone does not prove that a row or its data is ready. Prefer a condition tied to the app: the expected row index is present, a loading indicator disappears, a request-backed status changes, or the visible row set matches the target region. For an infinite-loaded list, wait for the next batch to settle before capturing it. If rows contain images or fonts that affect appearance, wait for those assets too.
For react-window, preserve the inline style prop passed to each row: it positions and sizes the row. FixedSizeList is suited to equal-height rows; VariableSizeList is for rows with varying sizes. Overscan keeps extra items around the visible window, which can reduce blank edges during scrolling, but it cannot render an arbitrarily long list by itself. Excessive overscan can hurt performance. See web.dev’s react-window guide and check the API for the version you use.
Infinite loading and changing data
Some lists contain unloaded rows that are expected to arrive only near the end of the current loaded range. In react-window infinite-loading patterns, isItemLoaded, itemCount, and loadMoreItems distinguish loaded rows from pending ones. Scroll to trigger the next batch, then wait until the requested items are loaded before capturing that region. Do not assume that a scroll position means all data up to that position has arrived.
For reproducible captures, use a stable dataset or freeze mutations while capturing. If rows are inserted, removed, or reordered during the pass, offsets can shift and the final set of images can contain duplicates or gaps. A stable row identifier or index is useful for checking coverage.
4. Capture a list element or build a single tall image
If the list is the only content you need, capture the list element after scrolling each region into view:
await list.screenshot({ path: `list-part-${part}.png` });
Element screenshots capture the selected element’s visible box; a virtualized element still may not contain every row at once. For a single tall output, combine the overlapping captures with an image tool that fits your pipeline, aligning by the overlap and removing duplicate pixels. Check that row boundaries line up and that the output dimensions remain within the limits of your image viewer or downstream service. Playwright does not provide a special virtualized-list stitching option.
If you control the app and need one complete tall capture, consider an app-specific capture mode that renders all required rows without virtualization. Preserve data ordering and wait until every row has rendered before taking the screenshot. This is an implementation strategy based on how windowing works, not a Playwright feature; it may not look exactly like the live interactive state.
5. Screenshot options and practical choices
| Option or choice | Use | Watch for |
|---|---|---|
fullPage: true |
Ordinary scrollable document content | Does not guarantee off-window virtual rows |
page.screenshot() |
One viewport state | Capture after scrolling to the desired position |
locator.screenshot() |
A particular list or component | Captures the element’s rendered state, not absent rows |
| Viewport size | Control the visible area and row count per capture | Changing dimensions may change responsive layout and row heights |
| Overlap between captures | Make later stitching easier and reduce gaps | Remove duplicate content during assembly |
| Wait condition | Ensure rows or data are ready | Choose an app-specific signal instead of guessing a fixed delay |
See the Playwright screenshot guide and Page screenshot API for supported options in your installed version.
6. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Full-page image has blank space or only some rows | Rows outside the virtual window were never rendered | Scroll the real container and capture successive states, or use a capture mode that renders all required rows |
| Scrolling the page does not move the list | The list has its own overflow container | Find the element whose scrollTop changes in the browser and scroll that element |
| Some captures repeat the same rows | Scroll step is too small, the list did not move, or data changed | Record scroll position and stable row identifiers; advance by a measured amount and check for progress |
| Rows are missing after a scroll | Capture occurred before rendering or data loading completed | Wait for a row-specific or loading-state condition before capturing |
| Infinite list stops before the expected end | The next batch was not triggered, or the script used stale scroll-height measurements | Scroll near the loader threshold, wait for the batch, then re-read geometry and continue |
| Stitched image has seams or duplicated rows | Captures lack overlap, row heights changed, or scroll offsets shifted | Capture with overlap, use stable data, and align on repeated content or known boundaries |
| Capture is visually different from the app | Viewport or device scale changes responsive layout; animations or live data changed | Keep viewport and scale consistent, disable or wait for animations where appropriate, and stabilize data |
| Screenshot command errors or browser will not launch | Playwright package, browser installation, or version setup is incomplete | Follow the installation instructions for the project’s Playwright version and browser engine |
7. Performance, reliability, and cost
Repeated scrolling and capture work grows with the number of viewport-sized regions. Larger viewports or a larger scroll step mean fewer captures, but changing the viewport can change layout and row sizes. Use a modest overlap for stitching and avoid overscanning the entire list: windowing exists to limit rendered DOM work. A capture-only render-all mode can be simpler for a controlled dataset, but rendering a very large list at once may use more memory and take longer.
For reliability, make the capture repeatable: fix the viewport, keep data stable, wait for app-specific readiness, verify scroll progress, and record which row range each image covers. Handle a changing list height by measuring again after loads. A fixed delay can be a fallback for an app with no readiness signal, but it can be both slower and less reliable than waiting for the actual condition.
With a local Playwright run, account for the browser and machine time needed to load the app, render rows, and write images. This guide makes no benchmark claim. If captures run in a hosted browser or screenshot service, check that service’s own pricing and limits before processing a large list.
8. Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server for developers. A single GET request can return an image or PDF. For a virtualized list, a normal URL screenshot still captures the page as rendered at capture time; the endpoint does not replace the scroll-through technique above when you need every virtual row. It can help when you need a clean screenshot of a chosen page state without managing a browser. See the ScreenshotNeo API documentation.
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}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await require('node:fs/promises').writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));
Replace the example URL with your target and supply your API key. 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. The Free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000. Learn about ScreenshotNeo, then sign up free for 1,000 screenshots a month, no card required.
FAQ
Does fullPage: true capture every row?
Not necessarily. It captures the page’s scrollable extent, while a virtualized list may only have a small window of rows rendered in the DOM.
Can I use overscan to capture the whole list?
Overscan adds a limited number of rows around the visible window. It is not intended to render an arbitrarily long list in one capture.
What if row heights vary?
Measure the scroll container and use a stable overlap. Avoid assuming a fixed pixel offset maps to the same row count throughout the list.
Should I disable virtualization for screenshots?
Only if you control the app and a capture-specific render-all view fits the data size and output you need. Otherwise, scrolling and capturing regions preserves the normal virtualized behavior.


