The Animation finished Promise and Cancellation
Part of Web Animations API Integration in Core CSS Animation Fundamentals.
The problem
A wizard animates between steps with await panel.animate(...).finished, then loads the next step. It works until a user double-clicks “Next”: the first animation is cancelled by the second, the first await throws, and the console fills with “Uncaught (in promise) AbortError”. In another flow, code awaits animation.finished after calling play() on an animation that had already finished once — and waits forever on a promise that resolved long ago, or resolves immediately when it should wait.
The promise is the right tool; it just has semantics that event-based code never had to think about.
Root cause analysis: promises that reject and get replaced
finished resolves or rejects exactly once per run. When the animation reaches its end, finished resolves with the animation object. When it is cancelled — by cancel(), by removing the element, by a CSS animation being removed from style — finished rejects with a DOMException named AbortError. Code that only handles the success path produces unhandled rejections.
Replaying creates a new promise. Once an animation has finished, its finished promise is settled. Calling play(), reverse() or changing its timing so it is no longer finished replaces animation.finished with a new pending promise. A reference captured before replay points at the old, already-settled promise. Always read animation.finished after the call that starts the run you want to wait for.
ready resolves when playback actually starts. animation.ready settles when the animation is no longer pending — for example after a pause or play is applied. It also rejects with AbortError if cancelled before starting.
CSS animations have the same promises. element.getAnimations() returns CSSAnimation and CSSTransition objects with finished and ready, which is a more reliable completion signal than end events, as described in handling animationend and transitionend reliably.
Step-by-step resolution
Production code pattern
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
/** Runs an animation and resolves to 'finished' | 'cancelled'. Never rejects on cancel. */
export async function run(el, keyframes, options, { signal } = {}) {
if (signal?.aborted) return 'cancelled';
const animation = el.animate(keyframes, reduce.matches ? { ...options, duration: 0 } : options);
const onAbort = () => animation.cancel();
signal?.addEventListener('abort', onAbort, { once: true });
try {
await animation.finished; // read AFTER starting this run
return 'finished';
} catch (err) {
if (err?.name === 'AbortError') return 'cancelled';
throw err; // real errors still surface
} finally {
signal?.removeEventListener('abort', onAbort);
}
}
// Wizard: a new click aborts the previous transition flow.
let flow;
nextButton.addEventListener('click', async () => {
flow?.abort();
flow = new AbortController();
const { signal } = flow;
const out = await run(currentStep,
[{ opacity: 1, translate: '0 0' }, { opacity: 0, translate: '-24px 0' }],
{ duration: 180, easing: 'cubic-bezier(0.4, 0, 1, 1)', fill: 'forwards' },
{ signal });
if (out === 'cancelled' || signal.aborted) return; // a newer click took over
showStep(nextIndex);
await run(nextStep,
[{ opacity: 0, translate: '24px 0' }, { opacity: 1, translate: '0 0' }],
{ duration: 240, easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)' },
{ signal });
});
/* The steps' resting styles do not depend on script fills. */
.step { opacity: 1; translate: 0 0; }
.step[hidden] { display: none; }
@media (prefers-reduced-motion: reduce) {
.step { transition: none; }
}
Rendering Impact: composite. The animations touch
opacityandtranslate; promise handling runs as microtasks after the animation’s state changes and adds no per-frame work. A zero-duration animation under reduced motion still resolvesfinishedon the next animation update.
Setting duration: 0 instead of skipping the animation entirely keeps the code path identical under reduced motion: the promise still resolves and fill still applies, so later steps behave the same. Skipping is also valid, but then the final styles must be applied some other way.
Verification checklist
Constraints and trade-offs
finishednever settles for an animation paused forever; add a timeout if pausing is possible.- A cancelled animation drops its fill, so the element returns to its base style immediately.
- Aborting one controller for a whole flow cancels every animation in it; use separate controllers for independent effects.
- Infinite animations never resolve
finished; do not await them. - Older engines may lack
finishedon CSS animation objects; feature-detect before relying on it.
Frequently asked questions
Why does awaiting animation.finished throw AbortError?
The animation was cancelled before it finished — by cancel(), by removing the element, or by the CSS animation being removed. Catch the error and treat it as a cancellation outcome.
Why does my await never resolve after calling play() again?
Or why does it resolve immediately? A finished animation’s promise is already settled. Calling play() creates a new promise, so read animation.finished again after play().
What is the difference between ready and finished?
ready settles when playback actually begins after a pending play or pause; finished settles when the animation reaches its end or is cancelled.
Can I await CSS animations the same way?
Yes. getAnimations() returns CSSAnimation and CSSTransition objects that have the same finished and ready promises.
Related
- Web Animations API Integration — the parent topic
- Coordinating Sequential Animations with Promises — sequencing built on these promises
- Inspecting Running Animations with getAnimations() — finding animations to await