ScreenshotNeo

BlogHow-to

Screenshot a Webpage with Delayed Animations and Dynamically Loaded Sections

Wait for the content you need, trigger scroll-based loading, and choose an animation policy before capturing a reliable Playwright screenshot.

By the ScreenshotNeo team4 October 202612 min read

For a reliable screenshot, wait for the specific content you need to appear, trigger any scroll-based loading, and then capture. A navigation event such as domcontentloaded or load tells you about document lifecycle; it does not prove that an application-rendered section is ready. Likewise, fullPage: true captures the full scrollable document but does not guarantee that scrolling-dependent code has run.

In Playwright, use a locator or another page-specific condition to establish readiness. If the page loads sections as they approach the viewport, scroll through the relevant area and verify the content before taking the full-page shot. Choose explicitly whether animations should be disabled or allowed, because that choice changes the captured visual state.

1. Install Playwright and prepare a page-specific readiness condition

The example below uses JavaScript with Playwright. Replace the URL and locator with the target page and the content that must be visible in the screenshot.

npm install playwright
npx playwright install chromium

Save this as screenshot.mjs. It waits for a target heading, scrolls the page in viewport-sized steps so scroll-triggered sections can load, waits for the required lower-page content, and captures the full page.

import { chromium } from 'playwright';

const url = process.argv[2] ?? 'https://example.com';
const readySelector = process.env.READY_SELECTOR ?? 'h1';
const requiredSelector = process.env.REQUIRED_SELECTOR ?? readySelector;
const output = process.env.OUTPUT ?? 'page.png';

const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });

try {
  await page.goto(url, { waitUntil: 'domcontentloaded' });

  // Wait for content that identifies the page or section you need.
  await page.locator(readySelector).waitFor({ state: 'visible' });

  // Trigger sections that load only when they approach the viewport.
  // This is a practical scroll strategy; adapt it to the site's layout and behavior.
  let previousHeight = 0;
  for (let pass = 0; pass < 20; pass += 1) {
    const height = await page.evaluate(() => document.documentElement.scrollHeight);
    if (height === previousHeight && pass > 0) break;
    previousHeight = height;

    for (let y = 0; y < height; y += 700) {
      await page.evaluate((position) => window.scrollTo(0, position), y);
      // Wait for a visible, page-specific signal when one is available.
      // This short pause gives scroll handlers a chance to run; it is not a
      // universal guarantee that arbitrary asynchronous content is ready.
      await page.waitForTimeout(100);
    }
  }

  // Assert the actual lower-page content needed by this capture is present.
  await page.locator(requiredSelector).waitFor({ state: 'visible' });

  // Return to the top so the screenshot starts at the expected scroll position.
  await page.evaluate(() => window.scrollTo(0, 0));
  await page.screenshot({
    path: output,
    fullPage: true,
    animations: 'disabled'
  });
} finally {
  await browser.close();
}

Run it with selectors that match the target page:

READY_SELECTOR='main h1' REQUIRED_SELECTOR='#pricing' OUTPUT='page.png' node screenshot.mjs https://example.com

If a page has no stable selector for its ready state, add one based on observable content, an application state exposed in the DOM, or a response your own application controls. Do not assume that a fixed delay will work across sites.

2. Pick the right navigation and content waits

page.goto() supports lifecycle milestones including commit, domcontentloaded, load, and networkidle. These are useful for deciding when to begin interacting, but application readiness is a separate condition.

Wait condition What it tells you When to use it
commit The navigation response has started loading. When you need to act as soon as navigation begins; it is usually too early for page content.
domcontentloaded The initial HTML has been parsed. A practical starting point when scripts or app content continue loading.
load The page load event has fired. When the page’s load event is meaningful, while remembering that client-side requests may continue afterward.
networkidle There were no network connections for at least 500 ms. Only when that network condition is specifically useful. Playwright discourages relying on it as a general testing readiness check.
Locator or app condition The particular element or state you care about is present. Prefer this for a screenshot of a specific section or application state.

For example, wait for a section heading and a chart to become visible instead of treating navigation completion as proof that both are ready:

await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.getByRole('heading', { name: 'Monthly report' }).waitFor({ state: 'visible' });
await page.locator('[data-chart-state="loaded"]').waitFor();

Use conditions your page actually exposes. A selector that merely exists in the initial HTML may not mean its data has loaded; if necessary, wait for visible text, a loaded-state attribute, a non-empty result, or another application-specific signal.

3. Load sections that depend on scrolling

Lazy images, iframes, IntersectionObserver callbacks, scroll-triggered reveals, and virtualized lists may depend on actual movement of the visual viewport. A full-page screenshot expands the capture area, but it should not be treated as a substitute for scrolling through the page first.

Scroll and verify the required content

Scroll in viewport-sized increments, allow the site’s scroll handlers to run, and check for the content that matters. The example’s 700-pixel step and 100-millisecond pause are starting values for that script, not universal guarantees. Replace them with a site-appropriate strategy and rely on an observable condition when one exists.

async function visitPageAndLoadSections(page, url, requiredSelector) {
  await page.goto(url, { waitUntil: 'domcontentloaded' });

  let lastHeight = 0;
  for (let pass = 0; pass < 20; pass += 1) {
    const height = await page.evaluate(() => document.documentElement.scrollHeight);
    if (height === lastHeight && pass > 0) break;
    lastHeight = height;

    for (let y = 0; y < height; y += 700) {
      await page.evaluate((position) => window.scrollTo(0, position), y);
      await page.waitForTimeout(100);
    }
  }

  await page.locator(requiredSelector).waitFor({ state: 'visible' });
}

This loop has a pass limit because some pages keep growing as new content arrives. It does not guarantee that every item in a virtualized list has been rendered. For a virtualized list, scroll through the data in the way the application expects and verify the target items before capture. For a known lazy image, wait for its load state or for a page-specific loaded marker.

Images and other assets

When an image is essential, verify that it loaded rather than relying only on the element’s presence. For a known image selector:

const image = page.locator('img.hero');
await image.waitFor({ state: 'visible' });
await page.waitForFunction(() => {
  const img = document.querySelector('img.hero');
  return img instanceof HTMLImageElement && img.complete && img.naturalWidth > 0;
});

Use the actual selector and expected asset state for your page. If the site swaps image sources responsively or renders imagery in a canvas, adapt the readiness check to that implementation.

4. Choose the animation state

For stable visual comparisons, disable animations in the screenshot. Playwright’s screenshot animation behavior fast-forwards finite animations to completion. Infinite animations are canceled to their initial state during capture and then played again. That means disabling animations affects which frame is represented.

  • Use animations: 'disabled' when the desired image is the settled state or when reducing animation-driven variation matters.
  • Allow animations when the intended image depends on motion or a naturally moving page. Do not disable them if doing so would produce the wrong state.
  • Set a specific application state first when you need a particular transition frame. For example, trigger the state through the application or wait for a state marker before capturing.
// Stable, settled screenshot
await page.screenshot({ path: 'settled.png', fullPage: true, animations: 'disabled' });

// Keep the page's current animation behavior
await page.screenshot({ path: 'current-state.png', fullPage: true });

A normal page.screenshot() call captures once; it does not automatically wait for two identical frames. For visual regression tests, Playwright Test’s toHaveScreenshot() retries until consecutive screenshots match before comparing with the expected image. It also supports masking and stylesheet controls for irrelevant variable regions. That retry behavior belongs to the assertion API, not every screenshot call.

5. Capture the viewport, a component, or the whole page

Choose the capture area based on what the screenshot must show.

Capture Example Use it for
Current viewport await page.screenshot({ path: 'viewport.png' }) The visible frame at the current viewport and scroll position.
Full scrollable page await page.screenshot({ path: 'full.png', fullPage: true }) A long page after relevant scroll-triggered content has been loaded and verified.
One component await page.locator('.report-card').screenshot({ path: 'card.png' }) A single element, once it is visible and in its intended state.

A component screenshot is often more reliable for a focused check because unrelated page sections do not affect the image. For a full-page image, consider fixed or sticky elements: depending on the page, they may appear in a way that differs from a single viewport capture. Validate the chosen capture area against the intended output.

6. Visual regression: wait for a stable screenshot

When the goal is comparison against a baseline, use Playwright Test’s screenshot assertion rather than assuming one screenshot call is stable. The assertion waits for consecutive identical screenshots before comparing. Mask regions that are expected to change and only when those pixels are irrelevant to the test.

import { test, expect } from '@playwright/test';

test('report page is visually stable', async ({ page }) => {
  await page.goto('https://example.com/report', { waitUntil: 'domcontentloaded' });
  await page.getByRole('heading', { name: 'Monthly report' }).waitFor({ state: 'visible' });
  await page.locator('[data-chart-state="loaded"]').waitFor();

  await expect(page).toHaveScreenshot('report.png', {
    fullPage: true,
    animations: 'disabled',
    mask: [page.locator('[data-testid="live-clock"]')]
  });
});

Keep masks narrow: masking a large area can hide a real regression. A stylesheet override can also stabilize a deliberately variable region, but it should preserve the page behavior relevant to the test.

7. Language and command-line alternatives

The browser setup above is JavaScript. Playwright also has Python bindings; the same principle applies: wait for an observable page condition, scroll if the target is scroll-gated, then capture.

Python with Playwright

from playwright.sync_api import sync_playwright

url = 'https://example.com'
ready_selector = 'main h1'
required_selector = '#pricing'

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page(viewport={"width": 1440, "height": 900})
    try:
        page.goto(url, wait_until='domcontentloaded')
        page.locator(ready_selector).wait_for(state='visible')

        previous_height = 0
        for _ in range(20):
            height = page.evaluate('document.documentElement.scrollHeight')
            if height == previous_height and previous_height > 0:
                break
            previous_height = height
            y = 0
            while y < height:
                page.evaluate('(position) => window.scrollTo(0, position)', y)
                page.wait_for_timeout(100)
                y += 700

        page.locator(required_selector).wait_for(state='visible')
        page.evaluate('window.scrollTo(0, 0)')
        page.screenshot(path='page.png', full_page=True, animations='disabled')
    finally:
        browser.close()

cURL and Node.js for an existing screenshot endpoint

cURL does not operate a browser or wait for a DOM locator. Use it only when you already have a screenshot service that accepts a URL and implements the rendering and readiness behavior you need. A generic request cannot guarantee that a dynamic section has loaded.

curl -G 'https://your-screenshot-service.example/shot' \
  --data-urlencode 'url=https://example.com' \
  -o page.png

Likewise, Node’s built-in fetch can call an existing screenshot API, but it does not itself render a web page:

const endpoint = new URL('https://your-screenshot-service.example/shot');
endpoint.searchParams.set('url', 'https://example.com');

const response = await fetch(endpoint);
if (!response.ok) throw new Error(`Screenshot request failed: ${response.status}`);
await Bun.write('page.png', new Uint8Array(await response.arrayBuffer()));

The Node example uses Bun’s file writer. With Node.js alone, save the response body using node:fs/promises:

import { writeFile } from 'node:fs/promises';

const endpoint = new URL('https://your-screenshot-service.example/shot');
endpoint.searchParams.set('url', 'https://example.com');
const response = await fetch(endpoint);
if (!response.ok) throw new Error(`Screenshot request failed: ${response.status}`);
await writeFile('page.png', Buffer.from(await response.arrayBuffer()));

8. Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server. Its screenshot API can wait for a selector, a delay, or network idle; check the ScreenshotNeo API documentation for the supported request parameters and choose a page-appropriate readiness option. A fixed delay is not a universal guarantee of application readiness.

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

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://example.com"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await import('node:fs/promises').then(({ writeFile }) => writeFile('shot.webp', Buffer.from(await res.arrayBuffer())));
  • Cookie banners are accepted and removed before the shot; newsletter popups and chat widgets are removed too.
  • Bot checks, blank pages, timeouts, and failed loads are never billed. Response headers report the page verdict and billing status; cache hits cost nothing.
  • An MCP server provides take_screenshot, get_page_info, and capture_pdf tools for AI agents and MCP clients.
  • The free plan includes 1,000 screenshots per month with no card. Paid plans start at $5 for 3,000 screenshots.

Create a free ScreenshotNeo account for 1,000 screenshots a month, with no card.

9. Reliability, performance, and cost

Reliability

  • Wait for the content that matters, not just a navigation milestone.
  • Give selectors explicit meaning. A visible heading, loaded-state marker, or expected content is stronger evidence than an arbitrary sleep.
  • Scroll only as far and as often as needed. Verify required lower-page content before taking the full-page capture.
  • Keep animation policy consistent across captures. If the desired state is a particular transition frame, arrange that state explicitly.
  • For visual tests, use the screenshot assertion’s stability behavior and mask only irrelevant variable regions.

Performance

Extra scrolling, readiness checks, and screenshot retries add work. Limit them to the sections and assets needed for the capture. A full-page image also covers more pixels than a viewport or element capture. Avoid waiting for general network quiet when a precise locator can establish readiness sooner; pages with continuing network activity may never reach a useful quiet state.

Cost

With a self-hosted Playwright script, account for the compute and browser time needed to load the page, scroll it, wait for content, and save the image. There is no single wait duration or universal cost estimate for arbitrary sites. With a screenshot API, review its billing rules, limits, and treatment of failed captures. ScreenshotNeo states that only clean shots are billed and that failed loads, bot checks, blank pages, timeouts, and cache hits cost nothing; see its plan details for current included volumes.

10. Troubleshooting

Symptom Likely cause Fix
The screenshot misses a section even though navigation completed. The section is rendered asynchronously after the lifecycle event. Wait for a locator or application state that identifies the required content.
A lazy image or section is blank in a full-page capture. Its loading is triggered by scrolling or viewport intersection. Scroll through the relevant area, wait for the element or loaded state, then capture.
The page keeps waiting for networkidle. Background requests, polling, or streaming keep network activity alive. Use a specific locator or application-ready condition instead of network quiet as a universal signal.
The screenshot differs between runs while animations are active. The captured frame varies with timing. Disable animations for a settled comparison, or set and verify the intended animation state.
Disabling animations produces an unexpected frame. Finite animations are fast-forwarded and infinite animations are canceled during capture. Allow animations or arrange the desired page state before taking the screenshot.
The required selector times out. The selector is wrong, the content never loads, or the page is in a different state. Check the selector against the live DOM, confirm the navigation succeeded, and wait for the correct state or error condition.
A virtualized list omits items outside the rendered window. The app only keeps nearby list items in the DOM. Scroll through the list and verify the target items; full-page capture cannot screenshot DOM content that was never rendered.
A screenshot API returns an error or unexpected image. The URL, authentication, readiness option, or remote page load may be invalid. Check the endpoint response and service headers, confirm the target URL is accessible, and use the API’s documented readiness settings.

11. FAQ

Is there a delay that works for every website?

No. Readiness depends on the site’s code and content. Use an observable page-specific condition instead of assuming a universal sleep duration.

Does fullPage: true scroll the page to load everything?

Do not rely on it to trigger scroll-dependent behavior. Scroll through relevant sections and verify that they rendered before capturing.

Should I always disable animations?

No. Disable them for a stable settled image; allow them when the desired capture depends on the motion or its current state.

Does toHaveScreenshot() behave like page.screenshot()?

No. The Playwright Test assertion retries until consecutive screenshots match before comparing. An ordinary screenshot call captures once.

References