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

  1. Are you starting the animation from JavaScript? Use WAAPI element.animate(...) and await its .finished promise. This is the most robust option and needs no duration constant.
  2. Is the animation declared in CSS @keyframes and only triggered by a class toggle? Read it back with element.getAnimations() and await the returned animation’s .finished, or listen for a one-shot animationend.
  3. Do you need to run several animations in parallel before continuing? Collect their .finished promises and await Promise.all([...]).
  4. Can a step be interrupted (route change, rapid re-trigger)? Wrap the await in try/catch and treat the AbortError rejection as a clean cancellation, not an error.
  5. Never reach for setTimeout to 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. The await points touch the main thread briefly to schedule the next step; the motion itself never leaves the compositor.

Verification checklist

Constraints and trade-offs

  • Animation.finished rejects on cancellation; an unhandled rejection surfaces as a console error, so every await chain that can be interrupted needs a try/catch.
  • fill: 'forwards' holds the end state but keeps the Animation alive, retaining a compositor layer — call animation.commitStyles() then animation.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.all for effects that should overlap rather than queue.
  • animationend does 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.