How to Create and Capture Infinite CSS Animations
Loop CSS animations with `infinite`, then capture either a live frame or a repeatable frame at a chosen time using Playwright and the Web Animations API.
Set animation-iteration-count: infinite (or include infinite in the animation shorthand) to make a CSS animation repeat. To capture it with Playwright, use animations: 'allow' for whichever frame is on screen when the screenshot happens. For a repeatable still, pause the specific animation, set its Web Animations API currentTime, then take the screenshot with animations allowed.
This guide covers the CSS loop, timing and direction controls, live and chosen-frame screenshots, screenshot assertion behavior, accessibility, and common capture problems. The examples use a 1.2-second animation.
1. Create a CSS animation that loops
Define the animation’s states in @keyframes, then apply the animation name and timing to an element. The iteration count defaults to 1; set it to infinite to keep repeating. MDN documents the iteration-count values, and its CSS animation guide explains keyframes and the shorthand.
<div class="pulse" aria-hidden="true"></div>
.pulse {
width: 64px;
height: 64px;
border-radius: 50%;
background: #3978f6;
animation: pulse 1.2s ease-in-out infinite alternate;
}
@keyframes pulse {
from { transform: scale(1); }
to { transform: scale(1.08); }
}
The shorthand specifies the name, duration, easing, iteration count, and direction. With alternate, the animation travels forward and backward on alternating iterations. Remove it if each cycle should play forward and then jump back to the start. That reset will look seamless only if the final visible state connects naturally to the first.
Relevant animation controls
| Control | What it affects | Example |
|---|---|---|
animation-name |
The matching @keyframes sequence. |
pulse |
animation-duration |
Time for one iteration. | 1.2s |
animation-timing-function |
How progress changes within an iteration. | ease-in-out |
animation-delay |
Wait before the animation starts. | 200ms |
animation-iteration-count |
Number of iterations, or infinite. |
infinite |
animation-direction |
Whether iterations run forward, backward, or alternate. | alternate |
animation-fill-mode |
Whether keyframe styles apply before or after playback. | both |
animation-play-state |
Whether playback is running or paused. | paused |
The shorthand form is concise, but if you omit a component it uses that property’s initial value. Use longhands when clarity matters or when changing one setting independently. See MDN’s animation reference for shorthand syntax and defaults.
2. Capture a live frame with Playwright
When the exact frame does not matter, let the animation run and capture the page. Playwright’s animations: 'allow' option leaves animations running, so the result depends on when the browser takes the screenshot. That is convenient for a quick image, but repeated runs can capture different frames. Playwright’s screenshot API reference describes the animation option.
Runnable Node.js example
Install Playwright and its Chromium browser in your project, then save this as capture-live.mjs. The script navigates to a page you control that contains the .pulse animation.
npm install playwright
npx playwright install chromium
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 900, height: 600 } });
try {
await page.goto('http://localhost:3000/animation.html', { waitUntil: 'load' });
await page.locator('.pulse').waitFor({ state: 'visible' });
await page.screenshot({ path: 'animation-live.png', animations: 'allow' });
} finally {
await browser.close();
}
Replace the local URL with your page. If it is a remote page, make sure it is accessible to the machine running Chromium. The example waits for the element to appear; it does not guarantee a particular animation phase.
3. Capture a repeatable frame at a chosen time
For a specific still, find the intended CSS animation, pause it, and set its currentTime in milliseconds. Then capture with animations: 'allow'; otherwise Playwright’s disabled-animation screenshot behavior can change the frame. Document.getAnimations() returns animations in effect, and the Web Animations API supports pausing and reading or setting currentTime. See MDN for getAnimations(), pause(), and currentTime.
Runnable Node.js example
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 900, height: 600 } });
try {
await page.goto('http://localhost:3000/animation.html', { waitUntil: 'load' });
await page.locator('.pulse').waitFor({ state: 'visible' });
await page.locator('.pulse').evaluate((element) => {
const animation = element
.getAnimations()
.find((item) => item.animationName === 'pulse');
if (!animation) {
throw new Error('CSS animation "pulse" was not found on .pulse');
}
animation.pause();
animation.currentTime = 600; // midpoint of a 1.2-second iteration
});
await page.screenshot({ path: 'animation-frame.png', animations: 'allow' });
} finally {
await browser.close();
}
This example assumes the element’s animation has started and has a 1.2-second duration. Change 600 to the desired point in the cycle. It combines the documented browser APIs into a practical capture sequence; it is not a claim that the code was tested against your page.
Choosing the right animation when there are several
getAnimations() can return CSS animations, transitions, and Web Animations API animations. Filter by the target element and animation name, as above. If multiple animations share a name or the element has several matching animations, add a more specific selector or inspect the returned animations before selecting one. Avoid pausing every animation on the page unless the whole scene should be frozen.
4. Understand Playwright’s animation modes
| Capture approach | Result | Use it for |
|---|---|---|
animations: 'allow' |
Animations keep running; screenshot phase can vary. | Quick captures where a precise frame is unnecessary. |
animations: 'disabled' |
Finite animations are fast-forwarded to completion; infinite animations are canceled to their initial state during capture, then played again. | Stable captures where the initial state is acceptable. |
Pause and set currentTime, then use 'allow' |
The selected animation is held at the chosen time. | Repeatable captures of a deliberate frame. |
Screenshot assertions also disable animations by default and wait for two consecutive identical screenshots. A scene that moves continuously may therefore be reset rather than captured mid-cycle. Configure the assertion’s screenshot options deliberately, or pause and seek the animation before asserting. See Playwright’s screenshot assertion reference.
5. Make motion accessible
Motion can affect people with vestibular disorders, epilepsy, migraine, or sensitivity to flashing. MDN recommends respecting reduced-motion preferences and providing a way to pause or disable motion where appropriate. Keep the essential content understandable without animation. See MDN’s animation accessibility guidance.
@media (prefers-reduced-motion: reduce) {
.pulse {
animation: none;
}
}
For an interaction that remains visible for a while, consider a user-facing pause control as well. A reduced-motion preference and a pause control serve different needs. Check that disabling motion leaves a meaningful static state.
6. Troubleshoot common capture problems
| Symptom | Likely cause | Fix |
|---|---|---|
| The screenshot always shows the first frame. | Playwright’s animation handling is disabled, or the animation has not started. | Use animations: 'allow' for a live frame, or pause the target animation, set currentTime, and then capture with 'allow'. Wait for the element and animation to exist first. |
| The screenshot changes between runs. | A running loop is captured at different points in its cycle. | Pause and set a fixed currentTime. Ensure the page uses the same viewport and animation duration on each run. |
| The animation cannot be found. | The selector is wrong, the element is not rendered yet, or the keyframes name does not match. | Wait for the element, inspect its computed styles, and confirm the animation name and selector. |
| The chosen frame looks like the wrong part of the loop. | currentTime is in milliseconds and the assumed duration may be wrong; delays and direction also affect visual progress. |
Check computed animation duration and delay. Seek to a time within the cycle and inspect whether alternate changes the direction at that point. |
| The loop visibly jumps at its boundary. | The final rendered state does not connect to the next iteration’s starting state. | Adjust keyframes or use alternate if back-and-forth motion is intended. Compare the boundary states rather than assuming infinite smooths the reset. |
| The page screenshot is blank or missing the animated element. | Navigation completed before the relevant content rendered, or the page uses a different route or selector. | Wait for a page-specific visible element, check the URL and browser console, and confirm the element is in the captured viewport. |
| A screenshot assertion times out on an animated page. | The scene never produces two identical frames, or its default animation handling resets it unexpectedly. | Pause and seek the animation before the assertion, or configure screenshot animation handling to match the intended still. |
7. Performance, reliability, and output format
- Prefer a chosen frame for repeatability. Letting a loop run makes the result timing-dependent. Pausing a specific animation removes that source of variation, though other changing content on the page can still affect the screenshot.
- Wait for the actual page state. Navigation’s
loadevent does not necessarily mean an animation’s target element is visible. Wait for the relevant selector before seeking or capturing. - Keep the capture environment consistent. Use the same viewport and page state when comparing screenshots; layout changes can alter what is visible even when the animation time is fixed.
- A screenshot is one still image. It does not preserve the animation. For a GIF or video, capture or render a complete cycle and check that its first and last frames join cleanly. The sources cited here document CSS looping and still screenshot controls, not a specific encoder or export pipeline.
- Account for the work involved. Browser automation requires launching a browser and loading the page. If you need a single still rather than an animation file, set the capture workflow up to wait only for the necessary page state and target element.
8. Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. It can return a screenshot from one GET request; for this page, it captures a still of the rendered animation rather than an animated export. Its API accepts common screenshot API parameter names, which can make switching straightforward. 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,
)
r.raise_for_status()
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 Bun.write('shot.webp', res);
- Cookie and consent banners are accepted like a visitor; 60+ known consent platforms, newsletter popups, and chat widgets are removed before the shot. Each step can be turned off.
- Bot checks and CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed. Response headers report the page verdict and billing status.
- An MCP server gives AI agents tools to take screenshots, get page information, and capture PDFs.
- The Free plan includes 1,000 screenshots a month with no card. Paid plans start at $5 for 3,000 screenshots. Every feature is on every plan.
Sign up for ScreenshotNeo’s free plan to get 1,000 screenshots a month with no card.
9. FAQ
How do I make a CSS animation loop forever?
Set animation-iteration-count: infinite, either as a longhand or as part of the animation shorthand.
Why does my screenshot show the first frame?
Playwright’s disabled-animation screenshot mode cancels infinite animations to their initial state during capture. Allow animations, or pause the target animation and set its currentTime before taking the screenshot.
How do I freeze an animation on a specific frame?
Find the relevant animation with element.getAnimations(), call pause(), set currentTime in milliseconds, and capture with Playwright’s animation mode set to 'allow'.
Can a screenshot save an infinite animation as a GIF?
No. A screenshot is a still image. A GIF or video requires recording or rendering multiple frames with a separate export workflow.


