ScreenshotNeo

BlogHow-to

How to Take a Full Screenshot of a Virtualized List in a React Web App

Playwright’s full-page option captures page height, not rows a virtualizer has not rendered. Scroll the list container, capture overlapping sections, and verify every row.

By the ScreenshotNeo team4 October 20267 min read

Playwright’s fullPage: true captures the page’s full scrollable extent, but it does not force a React virtualizer to render rows outside its current window. To capture every row reliably, identify the list’s actual scroll container, scroll it through the list, wait for each new window to render, and capture overlapping sections. If you need one tall image, use an application-controlled mode that renders every row, then verify the result.

This guide uses Playwright with TypeScript. The same method applies to virtualized lists built with react-virtualized or other virtualizers. Playwright’s screenshot documentation describes full-page and element screenshots; the virtualizer determines which rows exist in the DOM at each scroll position.

1. Choose the capture method

Situation Method Trade-off
Ordinary page without virtualization page.screenshot({ fullPage: true }) Simple full-page image. It does not solve missing virtualized rows.
List inside its own scroll panel Scroll the panel and capture overlapping sections Preserves normal rendering; produces multiple images unless you stitch them.
App provides a capture mode rendering all rows Enable that mode and take one tall screenshot Convenient, but may create a very tall, slow, or differently laid-out page.

Choose based on whether every row must be present, whether the normal layout must be preserved, whether the result must be one image, and how much application-specific setup is acceptable.

2. Identify the list’s scroll container

First determine whether the document scrolls or whether the list is in a nested panel. If the list panel owns scrolling, scrolling window will not advance it. Inspect the list in browser developer tools and check its scrollTop, clientHeight, and scrollHeight. A virtualizer may expose a rendered range callback; for example, react-virtualized’s onRowsRendered reports start and stop indices.

Give the list a stable selector when you control the app, such as data-testid="results-list". Prefer a selector for the actual element whose overflow-y scrolls, rather than a row or an outer wrapper.

3. Capture overlapping windows with Playwright

The example below scrolls the list container by slightly less than its viewport height, waits for scrolling and rendering to settle, and captures the visible list area at each position. It writes numbered PNG files. The overlap helps you inspect seams and detect skipped or repeated content. The exact selector and readiness condition must match your app.

import { chromium, type Locator } from 'playwright';
import { mkdir } from 'node:fs/promises';

async function waitForScrollToSettle(list: Locator) {
  // Wait until scrollTop stops changing across consecutive animation frames.
  await list.evaluate(async (element) => {
    let previous = -1;
    let stableFrames = 0;
    while (stableFrames < 3) {
      await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
      const current = element.scrollTop;
      if (Math.abs(current - previous) < 1) stableFrames++;
      else stableFrames = 0;
      previous = current;
    }
  });
}

const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
await page.goto('http://localhost:3000/results', { waitUntil: 'networkidle' });

const list = page.getByTestId('results-list');
await list.waitFor({ state: 'visible' });
await mkdir('screenshots', { recursive: true });

const metrics = await list.evaluate((element) => ({
  clientHeight: element.clientHeight,
  scrollHeight: element.scrollHeight,
  scrollTop: element.scrollTop,
}));
if (metrics.clientHeight <= 0) throw new Error('List has no visible height');

const step = Math.max(1, Math.floor(metrics.clientHeight * 0.8));
let index = 0;
let previousTop = -1;

while (true) {
  await waitForScrollToSettle(list);
  const top = await list.evaluate((element) => element.scrollTop);
  if (top === previousTop && index > 0) break;
  previousTop = top;

  await list.screenshot({ path: `screenshots/list-${String(index).padStart(4, '0')}.png` });
  index++;

  const atBottom = await list.evaluate((element) =>
    element.scrollTop + element.clientHeight >= element.scrollHeight - 1
  );
  if (atBottom) break;

  await list.evaluate((element, distance) => {
    element.scrollTop = Math.min(element.scrollTop + distance, element.scrollHeight);
  }, step);

  // Replace or supplement this with an app-specific signal, such as a row count
  // or a known rendered-range callback, if rows load asynchronously.
  await page.waitForTimeout(100);
}

console.log(`Captured ${index} sections`);
await browser.close();

Run it after installing Playwright and making the app available at the example URL:

npm install -D playwright
npx playwright install chromium
npx tsx capture-list.ts

The short timeout is a fallback, not a universal guarantee. Prefer waiting for an app-specific signal that confirms the expected rows have rendered. For react-virtualized, you can expose onRowsRendered state in the test app and wait until the rendered range covers the expected index near each scroll position.

4. Verify completeness and image quality

  • Check the endpoints: confirm the first and last expected rows appear in the captured sections.
  • Check overlaps: inspect adjacent captures for repeated or missing rows. The overlap should be large enough to compare recognizable content.
  • Check ordering: compare row IDs or indices against the expected order, especially if data can update during capture.
  • Check dynamic heights: variable-height rows can change scroll geometry. Measure actual scroll positions and use stable row markers when possible.
  • Check sticky content: sticky headers may appear in every section. Decide whether to keep them, hide them for capture, or account for them during stitching.
  • Check lazy media: wait for images and other content to load before capturing each section; otherwise screenshots may contain placeholders.
  • Check the viewport: preserve the intended width and device scale factor. Changing width can change wrapping, row heights, and the number of visible rows.

For a single stitched image, combine the sections with an image-processing step that accounts for their measured scroll positions and overlap. Avoid assuming every step moved the full requested distance: the final scroll is clamped at the bottom, and smooth scrolling or dynamic content can change positions. Review the stitched output for seams, duplicated sticky headers, and clipped rows.

5. When a single full-page screenshot works

If you control the React app, an intentional capture mode can temporarily render all rows instead of virtualizing them. Then a full-page screenshot may include the whole list:

await page.screenshot({ path: 'full-list.png', fullPage: true });

Test that mode carefully. Rendering every item can consume substantial memory and time, and may alter layout, row heights, ordering, or sticky behavior. Increasing overscan is not equivalent to rendering all rows: overscan generally adds rows near the visible bounds, not the entire dataset. If the list is too large to render at once, section captures are usually the practical choice.

6. Troubleshooting

Symptom Likely cause Fix
Only the visible rows appear in a “full-page” image The virtualizer never rendered off-screen rows. Scroll the real list container and capture sections, or add an app-controlled all-rows mode.
Scrolling has no effect The script scrolls the document or a wrapper instead of the list’s scroll element. Inspect which element’s scrollTop changes when a user scrolls; target that element.
Rows are missing between images The step is too large, the render wait is too short, or content shifted while capturing. Increase overlap, wait on a render signal, and stabilize the data before capture.
Rows repeat at section boundaries Overlap is expected, or stitching ignored actual scroll positions. Keep overlap for review; use measured positions and remove duplicated pixels when stitching.
Bottom rows are absent The loop stopped early, the list height changed, or the bottom was not reached. Check scrollTop + clientHeight against scrollHeight after rendering; verify the last row.
Images or text appear incomplete Lazy loading, async data, or fonts had not settled. Wait for the relevant content and fonts; use app-specific readiness checks rather than a fixed delay alone.
Capture hangs or grows very large Rendering all rows or taking a huge full-page image exceeds practical browser resources. Capture sections at a stable viewport size; avoid unbounded full-list rendering.
Layout changes between runs Viewport, device scale, data, animation, or timing varies. Fix viewport and scale, use stable test data, and disable or wait for app animations where appropriate.

7. Performance, reliability, and cost

Section capture takes multiple browser operations, so runtime grows with the number of windows. Smaller steps mean more overlap and easier seam checks but more screenshots; larger steps reduce work but increase the risk of gaps. Keep the viewport fixed and choose an overlap that makes rows easy to match. Very tall single images can use substantial memory and may be awkward to inspect or share.

For reliable runs, capture a stable dataset, wait for each virtualizer update, record the scroll positions, and verify first and last rows plus intermediate indices. If the app fetches more data while scrolling, wait for both the network update and the newly rendered rows. A timeout alone can be flaky on slow or variable environments.

Running Playwright locally has no per-screenshot API charge, though it uses browser and machine resources. If using a hosted browser or CI service, its own usage and pricing apply. For ScreenshotNeo pricing and behavior, see the product details below.

Or skip the browser setup

For ordinary pages, ScreenshotNeo takes a screenshot from one API request. It cannot drive a React virtualizer through its scroll positions, so a virtualized list still needs an app capture mode or browser automation that visits each window. For a page state your app has already rendered, use this request:

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

See the ScreenshotNeo API docs for request options. ScreenshotNeo accepts cookie and consent banners like a visitor and removes 60+ known consent platforms, newsletter popups, and chat widgets before capture; each step can be turned off. Bot checks, blank pages, failed loads, timeouts, and cache hits cost nothing, and responses identify the page verdict and billing status in headers. Its MCP server lets AI agents use screenshot, page-info, and PDF tools. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots. Every feature is on every plan. See ScreenshotNeo.

Sign up free for 1,000 screenshots a month, no card required.

FAQ

Does fullPage: true mean all virtualized rows are included?

No. It captures the page’s scrollable extent, while the virtualizer controls which rows are rendered.

Can I use this approach with a virtualized grid?

Yes, if you scroll the element that owns the relevant axis and capture enough overlapping sections. For grids that scroll horizontally and vertically, capture both axes and verify row and column coverage.

How do I know the capture is complete?

Verify the first and last expected rows and compare intermediate row IDs or rendered indices against the expected dataset.

Can I increase overscan to capture the entire list?

Overscan can render nearby rows outside the visible area, but it is not a guarantee that the full dataset is rendered. Use an explicit all-rows mode or scroll through the windows.