ScreenshotNeo

BlogHow-to

How to Handle Animations and Loading States in Visual Tests

Make visual tests reliable by capturing a known UI state. Learn when to disable animations, how to wait for real readiness, and how to debug flaky screenshots.

By the ScreenshotNeo team4 October 202610 min read

Reliable visual tests capture a known UI state: the right data is present, important resources are ready, and motion is either deliberately settled or deliberately part of the test. In Playwright, use toHaveScreenshot() for a settled-state snapshot: it waits for two consecutive screenshots to match, and its documented animations default is "disabled". Then assert that the application itself is ready. A fixed sleep can help only when there is no dependable completion signal; it is not proof that the page is ready.

1. Decide which visual state the test protects

Before changing timing or diff settings, define the contract. Should the snapshot show the final state after an entrance transition, the initial state before it begins, or a particular frame while an animation runs? Should a loading indicator still be visible, or should the test wait for the actual content? The right capture settings depend on those answers.

  • Settled UI: disable or finish finite browser animations, then assert that the meaningful content is ready.
  • Animation behavior: let motion run and assert a deliberate state, time, or frame. Disabling it could hide a regression.
  • Loading UI: capture the loading state intentionally, or wait for a user-visible ready condition before taking the final-state snapshot.

A matching screenshot is useful only if it represents the behavior you intend to protect.

2. Use Playwright’s screenshot assertion for settled states

Playwright documents that toHaveScreenshot() waits until two consecutive page screenshots match, then compares the last screenshot with the expected image. Its documented animations default is "disabled". For finite animations, disabling fast-forwards them to completion and allows their completion event to fire. Infinite animations are canceled to their initial state for capture and played again afterward. Those rules can affect which state appears in the image, so choose deliberately. See the Playwright PageAssertions documentation.

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

test('dashboard settled state', async ({ page }) => {
  await page.goto('http://localhost:3000/dashboard');

  // Wait for a condition that represents the content under test.
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
  await expect(page.getByTestId('dashboard-data')).toHaveAttribute('data-state', 'ready');

  // CSS and Web Animations are disabled by default for this assertion.
  await expect(page).toHaveScreenshot('dashboard.png');
});

Run it using your project’s installed Playwright version and configuration. For example, with the Playwright Test runner installed:

npx playwright test

The test uses a local application URL and app-specific selectors; replace those with your route and a readiness condition your application actually exposes. Do not treat the heading alone as proof that data, images, or fonts needed by the snapshot are ready.

Set the animation behavior explicitly when it matters

Make the intended behavior visible in the assertion. "disabled" is the documented default; "allow" leaves animations active. Playwright also documents "disabled" and "allow" as the available values for the screenshot assertion’s animation option. Check the API for your installed version because options and behavior can vary by version.

// Settled snapshot: finite animations finish; infinite ones are reset for capture.
await expect(page).toHaveScreenshot('settled.png', { animations: 'disabled' });

// Animation is part of the contract: leave it running and control the intended state.
await expect(page).toHaveScreenshot('motion.png', { animations: 'allow' });

Allowing motion alone does not make a moving frame deterministic. If the animation is under test, synchronize it with a controlled clock, explicit app state, or a test hook, and assert the intended point in its lifecycle. Avoid relying on whichever frame happens to be captured.

3. Wait for application readiness, not a generic load event

There is no single browser signal that proves every relevant page resource and asynchronous application update is complete. A navigation event can precede data fetching, lazy content, image decoding, or a later render. Network inactivity is also only a heuristic: an application may request something later, and unrelated background traffic can prevent quiet.

Wait for observable conditions tied to the content under test. Use Playwright’s normal locator assertions and app-specific readiness signals:

await page.goto('http://localhost:3000/catalog');

// The application sets this state only after the catalog data is rendered.
await expect(page.getByTestId('catalog')).toHaveAttribute('data-state', 'ready');
await expect(page.getByRole('heading', { name: 'Catalog' })).toBeVisible();
await expect(page.getByRole('list', { name: 'Products' }).getByRole('listitem')).toHaveCount(12);

await expect(page).toHaveScreenshot('catalog.png');

Expose a stable readiness marker when the application has a meaningful boundary, such as data loaded and the target content rendered. For an image that matters visually, assert that the image is present and usable; merely finding an <img> element does not establish that its resource has loaded.

const hero = page.getByRole('img', { name: 'Product overview' });
await expect(hero).toBeVisible();
await expect(hero).toHaveJSProperty('complete', true);

Confirm the image’s natural dimensions too when a broken or empty image would invalidate the snapshot. The exact locator and readiness check depend on how the app renders the image.

Make fonts and images predictable

  • Prefer local, version-controlled assets or stable test fixtures over resources from third-party hosts.
  • Wait for important content and images explicitly, especially when they load lazily or after interaction.
  • Ensure the test reaches the viewport or performs the interaction that triggers lazy loading before asserting readiness.
  • Use a controlled font source and wait for the UI to reach its intended typography before capture. A late font swap can change line wrapping and layout.
  • Investigate requests that begin after the initial render; initial network quiet does not guarantee future asynchronous work has finished.

Chromatic describes waiting for images and fonts and using network inactivity as a heuristic, while noting that resources requested asynchronously after initial rendering cannot be reliably predicted. Its guidance also identifies late fonts, images, and slow rendering as sources of instability. See Chromatic resource loading and debugging unstable tests.

4. Handle JavaScript-driven motion separately

Screenshot animation controls do not automatically settle every animation in the application. Browser-native CSS transitions, CSS and SVG animations, and videos may be paused or controlled by a visual testing tool, but JavaScript-driven motion often needs an application-level pause, a completion signal, or a controlled clock. Chromatic explicitly notes that JavaScript-driven animations may need to be paused by the test or allowed to complete. See Chromatic’s animation guidance.

For app-controlled motion, prefer one of these approaches:

  1. Test mode: provide a supported flag or fixture that renders the relevant component in a deterministic state.
  2. Completion signal: expose an accessible or test-only marker when the animation finishes, then wait for that marker.
  3. Controlled time: use the framework’s clock or deterministic animation driver, if the animation library supports it, and advance to the intended point.
  4. Pause control: pause the animation at a documented state before capture when that state is the contract.
  5. Short delay: use only when no reliable signal or control exists, and base it on observed behavior in the target environment. It remains less reliable than synchronization.

A delay example, as a last resort:

await expect(page.getByTestId('chart')).toBeVisible();
await page.waitForTimeout(250); // Fallback only; replace with a completion signal if possible.
await expect(page).toHaveScreenshot('chart.png');

The value is an example, not a universal recommended wait. A delay can be too short on a slow CI worker and unnecessarily long on a fast one.

5. Keep dynamic content deterministic

Random values, current timestamps, rotating promotions, live prices, and changing user data can make otherwise stable rendering differ between runs. Set fixture data and control the relevant state before capture. If a changing region is genuinely outside the visual contract, a mask can suppress its pixels in the snapshot assertion:

await expect(page).toHaveScreenshot('profile.png', {
  mask: [page.getByTestId('last-updated-time')],
});

Mask only content that is intentionally irrelevant. Do not hide a region whose appearance, presence, or behavior the test is supposed to catch. If an important element changes unexpectedly, control its data instead of masking it.

6. Choose a workflow that fits the review process

Approach Animation and readiness Good fit
Playwright screenshot assertion Waits for consecutive matching screenshots; documented animation default is disabled. Pair it with app-specific readiness assertions. Teams that want screenshot assertions and their output in the existing Playwright test workflow.
Hosted visual testing such as Chromatic Can proactively pause some browser-native motion and waits on resources with heuristics; JavaScript motion and later async requests still need deliberate control. Teams that want a hosted snapshot and review workflow and can make the rendered state deterministic.

The cited documentation does not establish a universally best tool, comparative price, or guaranteed stability rate. Choose based on whether local assertion output is sufficient or your team needs a hosted review workflow, and validate readiness and animation behavior in either environment. See Chromatic snapshots and Chromatic for Playwright.

7. Troubleshoot flaky visual snapshots

Symptom Likely cause Fix
Snapshot changes between runs during an entrance effect JavaScript animation is still moving, or the test captures different lifecycle points. Expose a pause or completion hook, control time, or assert a deliberate frame. Use animation disabling only when a settled state is the intended contract.
Text wraps differently in CI A font loaded late, a font request failed, or the CI environment rendered before the intended font was ready. Use a dependable font source, inspect the request and console output, and wait for the relevant UI to finish rendering.
Images are missing or appear late External resource variability, lazy loading, or a request that starts after initial rendering. Use controlled assets where possible, trigger lazy loading, assert important image readiness, and inspect network activity.
Test passes locally but fails in CI Different resource speed, browser or dependency version, environment data, or rendering timing. Check the installed Playwright version and browser, compare traces and console output, and make data and resources deterministic.
Increasing the delay does not fix the failure The page has no defined completion condition, or a value changes continuously. Identify the changing input and synchronize on meaningful app state. A longer sleep only shifts the timing window.
Masking makes the snapshot pass but hides a defect The masked content is part of the behavior or layout under test. Remove the mask and control the content or state. Keep masks limited to genuinely irrelevant dynamic regions.
Network-idle waiting hangs or still misses late content Background requests keep activity going, or async work starts after the quiet interval. Wait for the target content’s own ready condition; treat network quiet as a heuristic rather than proof.

When a snapshot remains unstable, inspect the test trace, console output, network activity, and captured DOM or app state before adding delays or widening diff thresholds. Find the changing input first; otherwise the test may become slower while remaining nondeterministic. Chromatic also recommends investigating resource behavior and unstable tests in its debugging guidance.

8. Performance, reliability, and cost considerations

  • Performance: resource waits, app readiness assertions, and screenshot stabilization all add time. Wait for the specific content the test needs rather than imposing a broad delay on every page.
  • Reliability: deterministic data, controlled assets, explicit readiness, and app-level animation hooks reduce dependence on machine speed. No one setting can make an uncontrolled app state reliable.
  • Maintenance: a useful readiness marker documents what the UI considers ready. Keep it tied to user-visible state so it does not silently pass before content renders.
  • Cost: the cited sources do not provide comparable pricing or measured cost data for these workflows. Consider the engineering time to maintain fixtures and the review process your team needs; do not infer a guaranteed savings from a screenshot tool.

9. Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server for developers. For a screenshot of a page, make one request instead of configuring a browser capture flow. See the ScreenshotNeo API documentation for the available 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}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer())));

ScreenshotNeo accepts cookie and consent banners and removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture; each step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits cost nothing, and response headers report the page verdict and billing status. Its MCP server provides take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 screenshots. API captures are useful for obtaining page images, while a visual test still needs deterministic data and a deliberate state if it is meant to catch regressions.

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

FAQ

Does disabling animations always capture the final frame?

No. Playwright fast-forwards finite animations but resets infinite animations to their initial state for capture. Check which state your component uses and whether that matches the test contract.

Is network idle enough to prove a page is ready?

No. It is a heuristic and cannot guarantee that future asynchronous requests or application updates have finished. Assert the relevant content’s ready state.

Should I increase the screenshot diff threshold?

Only when the accepted visual tolerance is intentional. First inspect changing data, animation state, fonts, images, and rendering conditions; a wider threshold can hide a real regression.

Can a screenshot assertion test an animation itself?

Yes, if the test controls or identifies the intended frame or state. Leaving motion active without synchronization can capture different frames on different runs.