How to Fix Puppeteer Screenshot Tests That Fail Because of Animations
Make Puppeteer screenshots repeatable by controlling reduced-motion preferences, CSS animation, JavaScript state, and page readiness.
Puppeteer screenshot tests can fail intermittently when a capture lands on different frames of an animation. Make the page state deterministic before capture: emulate reduced motion if the app honors it, disable irrelevant CSS motion with test-only styles, and wait for an application-specific signal when JavaScript drives the animation. waitUntil: 'networkidle2' can help with loading, but it does not stop animations.
Puppeteer does not document Playwright’s animations: 'disabled' screenshot option. Use Puppeteer’s own page APIs and your application’s state hooks instead. See the official Puppeteer screenshot API and page evaluation API.
1. Confirm animation is the source of the diff
Before changing a baseline, capture the same test state repeatedly and inspect which pixels differ. Check for CSS transitions, animated loaders, carousels, blinking cursors, canvas or WebGL frames, and hover or focus styles. A mismatch that moves between captures points to state or timing that needs controlling.
Keep the viewport, device scale factor, test data, fonts, locale, focus, pointer position, and time-dependent content consistent. If hover is not the intended state, move the pointer away from hover-sensitive controls; visual screenshot guidance from Playwright also calls out hover as a source of screenshot differences: Playwright visual comparisons.
2. Emulate reduced motion when the application supports it
Set the media feature before triggering the page state that would animate. Puppeteer documents Page.emulateMediaFeatures() and demonstrates prefers-reduced-motion: reduce. This is a preference signal: your CSS or application logic must honor it. It does not universally switch off motion. Check the API for your installed Puppeteer version, especially when using a version-specific or next API reference: emulateMediaFeatures.
await page.emulateMediaFeatures([
{ name: 'prefers-reduced-motion', value: 'reduce' },
]);
// Navigate or trigger the state whose screenshot the test needs.
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
await page.screenshot({ path: 'page.png' });
If navigation already happened, apply the emulation before triggering the animation. A page that does not use the media feature will look unchanged.
3. Neutralize CSS animations and transitions for the test
For irrelevant CSS motion, inject a test-only stylesheet before the screenshot. The broad rule below is simple, but it can change intended states and may interfere with components whose final appearance depends on animation timing. Prefer scoping the selector to the unstable component when practical.
await page.evaluate(() => {
const style = document.createElement('style');
style.dataset.testMotionOverride = 'true';
style.textContent = `
*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
}
`;
document.head.appendChild(style);
});
await page.screenshot({ path: 'page.png' });
For example, if only a rotating banner is noisy, target that banner rather than every element. Zeroing durations can expose an initial or final state depending on the page’s CSS and timing. It does not guarantee JavaScript-driven motion has settled.
4. Synchronize JavaScript-driven motion with the application
When CSS overrides do not stabilize the image, identify what drives the changing frame: a requestAnimationFrame loop, timer, animation library, canvas, or application state transition. Have the test wait for a signal that means the desired frame or state is ready. If the app supports a deterministic visual-test mode, use it to freeze time, select a fixed state, or stop the animation at a known point.
// Example only: replace this selector with a real readiness signal
// exposed by your application.
await page.waitForSelector('[data-visual-test-ready="true"]');
await page.screenshot({ path: 'page.png' });
The correct signal is application-specific. page.evaluate() can inspect page state or call an app-provided test hook, but a generic Puppeteer command cannot know which animation frame is correct. Avoid a fixed sleep as the default: it adds time and can still capture a different frame.
5. Use network idle for loading, not animation control
Puppeteer’s screenshot guide shows navigation with waitUntil: 'networkidle2', and waitForNetworkIdle() waits for a period of network inactivity. That can help when page content depends on requests. Network quiet is separate from visual stability: it does not document a guarantee that CSS or JavaScript animations have completed.
await page.goto('https://example.com', { waitUntil: 'networkidle2' });
// Still wait for the app's desired visual state if it animates.
await page.waitForSelector('[data-visual-test-ready="true"]');
await page.screenshot({ path: 'page.png' });
References: Puppeteer screenshots guide and Page.waitForNetworkIdle.
6. A complete Puppeteer test pattern
This CommonJS example demonstrates a repeatable setup for a page whose app exposes a readiness selector. Replace the URL and selector with your own. It assumes Puppeteer is installed in the project and that the test runner manages the Node.js process.
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage({
viewport: { width: 1280, height: 800 },
deviceScaleFactor: 1,
});
await page.emulateMediaFeatures([
{ name: 'prefers-reduced-motion', value: 'reduce' },
]);
await page.goto('https://example.com', { waitUntil: 'networkidle2' });
await page.addStyleTag({ content: `
/* Narrow this selector to the component that causes unstable diffs. */
.animated-banner, .animated-banner *,
.animated-banner *::before, .animated-banner *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
}
` });
// Use a real app-specific signal for JavaScript-driven state.
await page.waitForSelector('[data-visual-test-ready="true"]');
// Remove pointer hover as a variable when hover is not under test.
await page.mouse.move(0, 0);
await page.screenshot({ path: 'page.png' });
} finally {
await browser.close();
}
})();
Puppeteer documents screenshot capture and options separately; consult ScreenshotOptions for supported capture settings in the installed version. Do not add Playwright-only screenshot options to this Puppeteer example.
7. Pick the right control for the source of motion
| Approach | Best for | Controls | Limit |
|---|---|---|---|
| Reduced-motion emulation | Apps already honoring accessibility preferences | The media feature seen by page CSS and application code | Does not stop code that ignores the preference |
| Injected test stylesheet | CSS transitions or animations that add irrelevant pixel changes | CSS properties on chosen elements | Can alter intended appearance; does not settle JavaScript motion |
| Application readiness hook or test mode | Stateful animation, canvas, timers, JavaScript motion | The app-defined frame or state | Requires application support |
| Network idle | Request-dependent loading | Network activity quiet period | Does not control animation |
| Playwright animation option | Playwright screenshot assertions | Playwright’s documented animation handling | Not a Puppeteer option; see Playwright PageAssertions |
8. Troubleshooting common failures
| Symptom | Likely cause | Fix |
|---|---|---|
| Reduced-motion setting changes nothing | The app does not respond to prefers-reduced-motion, or emulation was set after the animation started |
Set it before triggering the state; add reduced-motion styling or use a test stylesheet/app hook |
| CSS still differs after setting duration to zero | The selector missed the animated node, a pseudo-element, or a nested component; or differing pixels come from non-animation state | Inspect the changing region, include relevant pseudo-elements, and scope the override to the actual component |
| Animation override creates the wrong visual state | Zero duration changes which keyframe or state is visible | Use a specific class/state or app test mode that sets the desired final state directly |
| Network idle returns but screenshot still varies | Network quiet does not imply visual stability | Wait for an application readiness signal and control CSS or JavaScript motion separately |
| Only one run fails, often around a loader | Capture timing or asynchronous app state varies | Wait for the loader to disappear or for a stable-state marker, then capture |
| Unexpected hover or focus diff | Pointer or focus state differs between runs | Move the pointer to a neutral location and set focus intentionally |
| Fonts or images cause shifting | Assets are not ready or layout is still changing | Wait for the app’s asset/layout readiness condition before capture; keep this separate from animation handling |
| Code reports an unknown option or method | API copied from Playwright or a different Puppeteer version | Check the installed package’s Puppeteer API docs; the screenshot options are documented at ScreenshotOptions |
9. Performance, reliability, and cost
Reduced-motion emulation and a small CSS override add little setup compared with launching a browser and loading a page. Network-idle waits and fixed delays can add latency; prefer waiting for a precise readiness signal when one exists. Reliability improves when each test fixes its viewport, page state, assets, pointer/focus state, and clock-sensitive content, then captures only after those conditions hold.
For CI, reuse a controlled browser lifecycle where your test runner permits it, close pages and browsers in cleanup paths, and avoid increasing timeouts to conceal a state race. Keep overrides test-only so normal users see the intended motion and accessibility behavior.
Or skip the browser setup
ScreenshotNeo is a screenshot API: one GET request with a URL returns a PNG, JPEG, WebP, or PDF. It can accept cookie and consent banners before capture and remove 60+ known consent platforms, newsletter popups, and chat widgets. You can turn each cleanup step 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 gives AI agents tools to take screenshots, get page information, and capture PDFs.
For animation-sensitive pages, this replaces your local browser setup for capture; it does not determine your app’s intended animation state, so use your application’s stable test URL or state when needed. 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}`);
ScreenshotNeo includes 1,000 screenshots per month free with no card; paid plans start at $5 for 3,000. Sign up for the free plan.
FAQ
Does Puppeteer have an animations: ‘disabled’ screenshot option?
Puppeteer’s documented screenshot options do not list that Playwright option. Use reduced-motion emulation, test CSS, or an application-specific readiness hook.
Will setting prefers-reduced-motion stop every animation?
No. It exposes a preference to the page. The page’s styles or code must respond to it.
Should I update the baseline after one stable run?
First repeat the capture under the same controlled conditions and confirm the changing pixels are resolved. Then review and update the baseline if the rendered state is the intended one.
Does networkidle2 wait for CSS animations to finish?
No documented animation-completion guarantee is attached to network-idle navigation. Use a separate visual-state signal.


