Coordinating Sequential Animations with Promises
Part of Animation State Management in Core CSS Animation Fundamentals.
The problem: setTimeout chains drift out of sync
A multi-step sequence — a dialog scales in, then its header slides down, then its list items stagger — is often wired with a setTimeout per step, each delay hand-tuned to the previous animation’s duration. It looks right on the developer’s machine and falls apart under load. The timers run on the main thread; the animations run on the compositor. As soon as the main thread is busy, the timers fire late while the animations keep their own time, so a step either overlaps its predecessor or lands after a visible gap. Duration numbers duplicated across CSS and JS also drift apart the moment someone edits one and not the other.
Root cause: two clocks that do not share a timeline
CSS animations and transitions are sampled on the compositor thread, advancing frame by frame independently of JavaScript. A setTimeout callback is queued on the main-thread task loop and only runs once the thread is free and the delay has elapsed. These are two separate clocks. A setTimeout(next, 300) intended to fire “when the 300 ms animation ends” is really firing “roughly 300 ms after the timer was set, whenever the main thread next gets around to it” — and those are not the same instant. The fix is to stop guessing durations and instead listen to the animation’s own completion signal.
Two signals exist. The animationend DOM event fires when a CSS @keyframes animation completes, carrying an animationName so you can tell which one finished. The Web Animations API goes further: every Animation object exposes a finished promise that resolves when the animation reaches its finished play state and rejects with an AbortError if the animation is cancelled. Promises compose with async/await, so a sequence becomes a linear, readable series of await animation.finished statements with no duration arithmetic and no drift — the next step cannot begin until the previous one has genuinely ended. This is the same lifecycle discipline covered for event-based flows in syncing CSS animations with JavaScript event loops.
Decision steps: pick the completion signal
- Are you starting the animation from JavaScript? Use WAAPI
element.animate(...)andawaitits.finishedpromise. This is the most robust option and needs no duration constant. - Is the animation declared in CSS
@keyframesand only triggered by a class toggle? Read it back withelement.getAnimations()andawaitthe returned animation’s.finished, or listen for a one-shotanimationend. - Do you need to run several animations in parallel before continuing? Collect their
.finishedpromises andawait Promise.all([...]). - Can a step be interrupted (route change, rapid re-trigger)? Wrap the
awaitintry/catchand treat theAbortErrorrejection as a clean cancellation, not an error. - Never reach for
setTimeoutto approximate any of the above — it reintroduces the drift you are trying to remove.
Production pattern: an async sequence with cancellation handling
Each step returns its Animation so the chain can await real completion. Every animated property is transform or opacity, so each step runs on the compositor thread. The whole sequence short-circuits for reduced-motion users.
// Intent: scale a dialog in, then slide its header, then stagger its items —
// each step starting only after the previous one has genuinely finished.
const prefersReducedMotion =
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)';
// Each helper returns the Animation object so its .finished promise is available.
function fadeScaleIn(el) {
return el.animate(
[
{ opacity: 0, transform: 'scale(0.92)' },
{ opacity: 1, transform: 'scale(1)' },
],
{ duration: 240, easing: EASE, fill: 'forwards' }
);
}
function slideDown(el) {
return el.animate(
[
{ opacity: 0, transform: 'translateY(-12px)' },
{ opacity: 1, transform: 'translateY(0)' },
],
{ duration: 200, easing: EASE, fill: 'forwards' }
);
}
async function playSequence(dialog, header, items) {
// Accessibility gate: no sequence — jump straight to the resolved end state.
if (prefersReducedMotion) {
for (const el of [dialog, header, ...items]) {
el.style.opacity = '1';
el.style.transform = 'none';
}
return;
}
try {
// await .finished — the compositor tells us the true completion instant.
await fadeScaleIn(dialog).finished;
await slideDown(header).finished;
// Run the item stagger in parallel, then wait for all of them together.
const staggered = items.map((item, i) =>
item.animate(
[
{ opacity: 0, transform: 'translateY(8px)' },
{ opacity: 1, transform: 'translateY(0)' },
],
{ duration: 180, delay: i * 40, easing: EASE, fill: 'forwards' }
).finished
);
await Promise.all(staggered);
} catch (err) {
// finished rejects with AbortError if an animation is cancelled
// (e.g. the dialog closed mid-sequence). That is expected, not a bug.
if (err.name !== 'AbortError') throw err;
}
}
Rendering Impact:
transform+opacity— composite only. Theawaitpoints touch the main thread briefly to schedule the next step; the motion itself never leaves the compositor.
Verification checklist
Constraints and trade-offs
Animation.finishedrejects on cancellation; an unhandled rejection surfaces as a console error, so everyawaitchain that can be interrupted needs atry/catch.fill: 'forwards'holds the end state but keeps theAnimationalive, retaining a compositor layer — callanimation.commitStyles()thenanimation.cancel()if you need to release it, per layer lifecycle strategy.- Awaiting steps strictly in series adds each step’s duration to the total; use
Promise.allfor effects that should overlap rather than queue. animationenddoes not fire if the animation is cancelled or its element is removed, so a pure event-based chain can stall — WAAPI’s rejecting promise is safer for interruptible flows.- Sequencing coordinates timing, not accessibility; a long chain is still motion and must honour reduced-motion preferences.
Frequently asked questions
Why is setTimeout unreliable for sequencing animations?
A setTimeout delay is a guess at the animation’s duration, but the timer runs on the main thread and fires late whenever the thread is busy, while the animation itself runs on the compositor and finishes on its own schedule. The two clocks drift apart, so the next step can start before the previous animation has actually finished, producing overlap or a visible snap.
What does the Animation.finished promise resolve with?
It resolves with the Animation object once the animation reaches its finished play state. If the animation is cancelled before completing, the promise rejects with a DOMException whose name is AbortError, which lets you distinguish natural completion from interruption in a try/catch.
Related
- Animation State Management — the parent guide on race-free animation state
- Syncing CSS Animations with JavaScript Event Loops — the thread-timing model behind these completion signals
- Accessible Motion Architecture — gating sequenced motion on user preference