Controlling CSS Animations From JavaScript
The choice between CSS animations and the Web Animations API is often presented as either-or: declare motion in stylesheets, or build it in script. In practice the best split is usually both. Keyframes, durations and easing live in CSS, where designers can read them and media queries can adjust them; script controls when and how the animation plays — pausing it, seeking to a point, reversing it, waiting for it to end. The bridge between the two is element.getAnimations(), which returns every running CSS animation and transition as a live object with the full Web Animations API. This page covers that bridge, the events and promises for sequencing, and the right way to restart a keyframe animation. It belongs to CSS Animation vs Web Animations API in the CSS-Only Micro-Interactions & Animations guide.
CSS animations are Animation objects
When a CSS rule applies animation-name, the browser creates a CSSAnimation object. Transitions create CSSTransition objects. Both extend the Animation interface from the Web Animations API, so they have play(), pause(), reverse(), cancel(), finish(), currentTime, playbackRate and a finished promise. element.getAnimations() returns those attached to one element; document.getAnimations() returns all of them on the page.
The complete implementation
A progress bar that animates in CSS, with script controls for pause, resume, scrubbing and waiting for completion.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Controlling CSS animations</title>
<style>
body { font: 15px/1.5 system-ui, sans-serif; margin: 2rem; }
.loader {
inline-size: 12rem;
block-size: 0.5rem;
border-radius: 999px;
background: #e2e8f0;
overflow: hidden;
}
.loader__bar {
block-size: 100%;
background: #2563eb;
transform-origin: left;
animation: fill 4s linear forwards;
}
@keyframes fill { from { scale: 0 1; } to { scale: 1 1; } }
@media (prefers-reduced-motion: reduce) {
.loader__bar { animation-duration: 1ms; }
}
</style>
</head>
<body>
<div class="loader"><div class="loader__bar"></div></div>
<p>
<button type="button" id="toggle">Pause</button>
<button type="button" id="restart">Restart</button>
<input type="range" id="scrub" min="0" max="1" step="0.01" value="0" aria-label="Scrub">
</p>
<p id="status" aria-live="polite"></p>
<script>
const bar = document.querySelector('.loader__bar');
// The CSS animation, as a Web Animations API object.
const [anim] = bar.getAnimations();
const duration = anim.effect.getComputedTiming().duration;
document.getElementById('toggle').addEventListener('click', (e) => {
if (anim.playState === 'running') { anim.pause(); e.target.textContent = 'Resume'; }
else { anim.play(); e.target.textContent = 'Pause'; }
});
document.getElementById('restart').addEventListener('click', () => {
anim.currentTime = 0;
anim.play();
waitForEnd();
});
document.getElementById('scrub').addEventListener('input', (e) => {
anim.pause();
anim.currentTime = Number(e.target.value) * duration;
});
async function waitForEnd() {
try {
await anim.finished;
document.getElementById('status').textContent = 'Upload complete';
} catch {
// Rejected: the animation was cancelled (for example, the class was removed).
}
}
waitForEnd();
</script>
</body>
</html>
The script never mentions 4s, linear or the keyframes. It reads the duration from the object's computed timing, so if a designer changes the CSS — or the reduced-motion query shortens the duration to a millisecond — the controls keep working. That separation is the main benefit of controlling CSS animations rather than recreating them in script. It also means the page still animates if the script fails to load: the CSS plays on its own, and only the extra controls are missing, which is a sensible way to degrade.
Reading progress
The object also answers questions CSS cannot. anim.effect.getComputedTiming().progress returns a number between 0 and 1 for the current iteration, and currentIteration reports which loop a repeating animation is on. That makes it possible to synchronise something non-visual with a CSS animation — announcing "halfway" in a live region, or starting a sound at a keyframe — without duplicating the timing in script. Read these values inside a requestAnimationFrame callback rather than on a timer, so they line up with rendered frames.
The key technique: restart through the object, not the class
The most common script-and-CSS bug is the restart that does nothing. Removing a class and immediately re-adding it happens within one task; the browser never computes a style without the class, so the animation never stops and never restarts. The old workaround was to force a style flush between the two steps (reading offsetWidth), which works but costs a synchronous layout.
With the object in hand, restarting is explicit: set currentTime = 0 and call play(). If the animation has finished and is holding its end state with forwards, the same two lines replay it. If you want the element to snap back to its unanimated style first, call cancel() before play().
Events versus the finished promise
CSS animations dispatch animationstart, animationiteration, animationend and animationcancel events; transitions dispatch transitionrun, transitionstart, transitionend and transitioncancel. Events bubble, which is useful for delegation — one listener on a list can react to every item's animation — but it also means a child's animation end can trigger a parent's handler unexpectedly. Check event.target and event.animationName before acting.
The finished promise is scoped to one animation and composes with async code: await Promise.all(el.getAnimations().map((a) => a.finished)) waits for every animation and transition on an element, which is exactly what you need before removing an element after its exit animation. Remember that the promise rejects when the animation is cancelled — by removing the class, setting display: none, or changing animation-name — so wrap it in try/catch or attach a rejection handler.
Script play state overrides CSS play state
Calling pause() or play() on a CSSAnimation has a lasting side effect: from then on, the animation ignores animation-play-state from the stylesheet. The specification treats the script call as an explicit instruction that takes precedence. In practice this means mixing the two control styles on the same animation produces confusing results — a CSS pause toggle stops working once any script has called play(). Pick one owner for playback per animation. If a page has a global CSS pause control and a script-driven component, have the component's script also respect the control, for example by checking matchMedia('(prefers-reduced-motion: reduce)') or the pause checkbox before calling play().
Coordinating several animations
getAnimations() is especially useful when several elements animate together and something must happen after all of them finish. A staggered list exit, for example, applies an exit class to every item, then waits:
list.classList.add('is-leaving');
const running = list.getAnimations({ subtree: true });
await Promise.allSettled(running.map((a) => a.finished));
list.remove();
The subtree: true option collects animations on descendants as well as the element itself, and Promise.allSettled waits even if some animations are cancelled along the way. Because the durations and delays live in CSS, the script does not need to know how long the stagger takes; it waits exactly as long as the stylesheet says. The same pattern pauses every animation on a page for a debugging session: document.getAnimations().forEach((a) => a.pause()).
What script should not change
Once you hold the object, it is tempting to set anim.effect.updateTiming({ duration: 800 }) or replace its keyframes. That works, but it silently disconnects the animation from its CSS: later changes to the stylesheet no longer apply to that animation, because script-set values override CSS-derived ones for its lifetime. It also bypasses media-query adjustments such as reduced motion. If timing needs to vary, prefer changing a custom property the CSS reads, such as --fill-duration, and let the stylesheet remain the source of truth.
Variation: pausing without script
For a single pause control, script is not even necessary. A checkbox and :has() can set animation-play-state on any animation in the page:
body:has(#pause-motion:checked) .loader__bar {
animation-play-state: paused;
}
This is the CSS-only approach explored in Pause Controls for Looping Animations. Reach for getAnimations() when you need what CSS cannot express: seeking, reversing, awaiting completion, or coordinating several animations.
Browser support
The Web Animations API, including element.animate(), getAnimations() and the finished promise, is supported in Chrome 36+, Edge 79+, Firefox 48+ and Safari 13.1+ for the core API; some members, including getAnimations(), arrived in later versions of those browsers, so test the methods you rely on. animation-play-state is supported in Chrome 43+, Edge 12+, Firefox 16+ and Safari 9+. @keyframes is supported in Chrome 43+, Edge 12+, Firefox 16+ and Safari 9+.
FAQ
Can JavaScript pause a CSS animation?
Yes. Either set animation-play-state: paused through a class or style, or get the animation object with element.getAnimations() and call pause(). The object approach also lets you seek with currentTime and change playbackRate.
How do I know when a CSS animation has finished?
Listen for the animationend event, or await the finished promise on the CSSAnimation object returned by getAnimations(). The promise is cleaner in async code; note that it rejects if the animation is cancelled, for example by removing the class.
How do I restart a CSS keyframe animation?
Removing and re-adding the class in the same frame does nothing, because the style never changes between frames. Call animation.cancel() then play() on the object from getAnimations(), or set currentTime to 0 on the running animation.
Are CSS animations and Web Animations API animations the same objects?
CSS animations and transitions appear in getAnimations() as CSSAnimation and CSSTransition objects, which extend the Web Animations API's Animation interface. You can control them with the same methods, while their keyframes and timing still come from CSS.
Related
- CSS Animation vs Web Animations API — the parent guide.
- When to Use JavaScript Animation — the decision in general.
- animation-fill-mode Explained — what forwards holds after finishing.
- Interrupted and Reversing Transitions — what happens mid-flight.
Related articles
More pages in the same section.