How to Capture Web Animations in Screenshots
Capture a stable frame, a finished animation, or a specific moment with Playwright and Puppeteer. Learn how to avoid timing noise in screenshot tests.
A screenshot captures one still frame, so an animation can look different depending on when the capture happens. For a repeatable Playwright screenshot, use animations: 'disabled'. For a particular moment in the motion, leave animation enabled and synchronize the capture with the page’s state. For a visual regression assertion, Playwright’s toHaveScreenshot() waits for consecutive screenshots to match before it compares the result.
Choose the frame you want first: a stable baseline, the finished state, a deliberate intermediate state, or an animation inspected interactively. These goals need different capture methods.
1. Choose what the screenshot should show
| Goal | Approach | Important detail |
|---|---|---|
| Stable baseline with motion suppressed | Playwright screenshot with animations: 'disabled' |
Finite animations are fast-forwarded; infinite animations are canceled to their initial state for the capture. |
| Finished animation state | Wait for an application state or animation completion, then capture | Do not assume a fixed sleep works for every page. |
| Specific point during motion | Trigger the animation and capture at a deliberately synchronized point | Leave animations enabled; disabling them does not freeze every animation at its current instant. |
| Hands-on inspection or frame scrubbing | Chrome DevTools Animations panel | It supports CSS animations, transitions, Web Animations and View Transitions. Its guide says requestAnimationFrame animations are not supported. |
2. Capture a deterministic screenshot with Playwright
Install Playwright and its browser using the official setup instructions, then use the following in a Node.js script with a page open. The animations option is documented in the Playwright Page screenshot API.
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
await page.goto('https://example.com', { waitUntil: 'load' });
await page.screenshot({ path: 'page.png', animations: 'disabled' });
await browser.close();
})();
The ordinary page.screenshot() default is animations: 'allow'. With 'disabled', CSS animations, CSS transitions and Web Animations are handled for the screenshot: finite animations are fast-forwarded to completion, while infinite ones are canceled at their initial state and resumed after capture. This is useful for removing motion-phase variation, but it may produce a completed state rather than the frame currently on screen.
Capture only one animated element
Use a locator screenshot when the target is a single component. The screenshot still needs a deliberate animation policy.
const animatedCard = page.locator('[data-testid="animated-card"]');
await animatedCard.screenshot({ path: 'card.png', animations: 'disabled' });
Make sure the locator identifies one element. Element screenshots may scroll the target into view, which can trigger page behavior such as sticky headers or scroll-driven effects. If those affect the intended image, set up the page’s scroll position before capturing and verify the resulting viewport.
Use a visual screenshot assertion
In a Playwright Test test file, toHaveScreenshot() waits until two consecutive screenshots are identical and compares the last one. Its documented animation default is disabled. Screenshot assertions require the Playwright test runner.
const { test, expect } = require('@playwright/test');
test('animated page has a stable visual baseline', async ({ page }) => {
await page.goto('https://example.com');
await expect(page).toHaveScreenshot('page.png');
});
Use this for regression checks where a stable still is the requirement. A changing timestamp, rotating content, blinking caret or other non-animation difference can still prevent consecutive screenshots from matching; address that source of variation separately.
3. Capture a chosen state while motion is running
Leave animation enabled when the image needs to show an intermediate frame. Trigger the event, wait for a page-specific signal that identifies the desired state, and capture. There is no universal safe delay: animation duration and the meaningful moment vary by page.
// Example: the application exposes a state attribute when the desired frame is reached.
await page.locator('#start-animation').click();
await page.locator('.animated-panel[data-state="highlighted"]').waitFor({ state: 'visible' });
await page.screenshot({ path: 'highlighted-state.png', animations: 'allow' });
Prefer an application event, state attribute, or animation lifecycle signal over an arbitrary waitForTimeout(). If the application exposes none, add a test hook or observe a specific property/state in the test. A delay can be useful for a known fixed-duration demo, but it can become flaky under different machines, load conditions, or reduced-motion settings.
Use the browser animation event when appropriate
For a finite Web Animation, the Web Animations API exposes a finished promise. This browser-side example waits for matching animations on an element to finish before capturing.
await page.evaluate(async () => {
const element = document.querySelector('.animated-panel');
if (!element) throw new Error('Animated panel was not found');
const animations = element.getAnimations();
await Promise.all(animations.map(animation => animation.finished));
});
await page.screenshot({ path: 'after-animation.png', animations: 'allow' });
This pattern is for finite animations. An infinite animation’s finished promise does not resolve while it continues running, so use a deliberate application state or controlled animation time for that case. Also account for animations added after the query if the page starts them asynchronously.
4. Capture with Puppeteer
Puppeteer documents both page-level Page.screenshot() and element-level ElementHandle.screenshot(). A basic Node.js capture looks like this:
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto('https://example.com', { waitUntil: 'load' });
await page.screenshot({ path: 'page.png', fullPage: true });
await browser.close();
})();
For one element:
const element = await page.$('.animated-panel');
if (!element) throw new Error('Animated panel was not found');
await element.screenshot({ path: 'panel.png' });
To make an animated capture repeatable in Puppeteer, control the page’s animation state in your test setup: wait for the application’s completion signal for a final-state image, or use page-side styling/test hooks to suppress motion for a baseline. Puppeteer’s cited screenshot guide documents page and element capture; it does not establish Playwright’s animations: 'disabled' option as a Puppeteer option.
5. Inspect and scrub animations in Chrome DevTools
- Open DevTools and select the Animations panel from the More tools menu.
- Trigger the effect while the panel is open so DevTools can detect it.
- Use the panel’s controls to replay or scrub the supported animation to the frame you want to inspect.
- Capture the browser view with your usual screenshot method.
The Animations panel supports CSS animations, CSS transitions, Web Animations and View Transitions API animations. The Chrome guide states that requestAnimationFrame-driven animations are not yet supported by that panel. For those, use application-level controls or browser automation.
6. cURL, Python and Node.js options for screenshot APIs
A screenshot API can capture a URL without you managing a local browser process. These examples use ScreenshotNeo’s documented endpoint and parameters; see the ScreenshotNeo API documentation for the available request options. An API returns a captured still, so use browser automation when you need to trigger an interactive animation and coordinate a particular in-page moment.
cURL
curl -G "https://api.screenshotneo.com/v1/shot" \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://example.com \
-o shot.webp
Python
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)
Node.js
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}`);
const image = Buffer.from(await res.arrayBuffer());
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', image));
7. Reliability, performance and cost considerations
- Synchronize to intent. A stable baseline and an intermediate frame are different requirements. Use disabled animations for stable comparison; use a page-specific signal for a chosen running state.
- Control the environment. Keep viewport, device scale, browser version, fonts, data, and page state consistent between visual regression runs. This reduces unrelated image differences.
- Prefer event-driven waits. Fixed sleeps waste time when the page is ready early and can still be too short under load. Wait for a state that means the target frame is ready.
- Be careful with infinite motion. A spinner or marquee can be canceled at an initial state by Playwright’s disabled-animation treatment, which may not be the visual you intend to baseline.
- Manage browser resources. Reuse a browser process across related captures when appropriate, isolate contexts for independent state, and close pages and browsers on success or failure.
- Account for capture scope. Full-page screenshots can include content that loads on scroll; element screenshots can cause scrolling. Check lazy content and scroll-dependent effects.
Local browser automation has setup and runtime costs: browser installation, compute, and maintenance of the capture environment. A hosted screenshot API shifts browser management to the service and charges according to its plan and billing rules. ScreenshotNeo’s free tier includes 1,000 shots per month with no card; paid plans are Starter $5 for 3,000, Growth $15 for 15,000, Pro $39 for 60,000, Scale $99 for 250,000, and Business $249 for 1,000,000. Yearly billing gives two months free. All features are on every plan. For animation-driven pages, verify the resulting frame matches your needs before choosing an API workflow.
8. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| The image changes between runs | The capture lands at a different point in an enabled animation, or other page content is dynamic. | Disable animation for a baseline, or synchronize to a meaningful state. Control other changing page data too. |
| The screenshot shows the end of an animation instead of its current frame | Playwright fast-forwards finite animations when animations: 'disabled' is used. |
Use 'allow' and wait for the desired intermediate state. |
| A spinner or looping animation disappears or jumps | Infinite animations are canceled at their initial state for a disabled Playwright capture, then resume. | Use a controlled visual state, hide the spinner in a test-specific setup, or capture with animation enabled at a synchronized moment. |
toHaveScreenshot() keeps waiting or fails |
Two consecutive screenshots are not identical, perhaps due to motion, a caret, changing data, or asynchronous content. | Stabilize the page and its inputs; use the assertion’s documented animation behavior and remove other sources of change. |
| The expected element is missing | The selector is wrong, the element has not rendered, or a transition has not reached the expected state. | Check the selector and wait for the target state before taking an element screenshot. |
| The DevTools Animations panel shows nothing | The effect was not triggered while the panel was listening, or it is a requestAnimationFrame animation. |
Open the panel before triggering supported effects. For requestAnimationFrame motion, inspect or control it through the application or automation. |
| An element screenshot changes page layout | The capture operation may bring the element into view and scroll the page. | Set the intended scroll position and account for sticky or scroll-triggered content before capturing. |
9. Or skip the browser setup
For a URL screenshot without managing a browser locally, make one request to ScreenshotNeo. See the API documentation for options.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
ScreenshotNeo accepts cookie and consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture; each cleanup step can be turned off. Bot checks, blank pages, and failed loads are never billed, and response headers report the page verdict and billing status. Its MCP server gives AI agents tools for screenshots, page information, and PDF capture. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000. It is a good fit for clean captures of URL states; use Playwright when you need to trigger an interaction and capture a chosen animation frame.
Sign up for 1,000 free screenshots a month, with no card required.
10. FAQ
Does a screenshot contain the whole animation?
No. A screenshot is a still image of one captured frame. Record a video if the motion itself must be preserved.
Does disabling animations freeze them at the exact current frame?
No. Playwright fast-forwards finite animations and cancels infinite animations at their initial state for the screenshot, then resumes infinite animations afterward.
Can I take a screenshot of a CSS transition?
Yes. Playwright’s screenshot animation handling covers CSS transitions as well as CSS animations and Web Animations. For a particular transition moment, keep animation enabled and synchronize to the desired state.
Can DevTools scrub every kind of web animation?
No. Its Animations panel supports several common browser animation types, but the Chrome guide says requestAnimationFrame animations are not yet supported.


