Skip to content

Animation Orchestration

When to Use

When CSS transitions are not enough — you need to sequence animations, respond to mid-animation interruptions, coordinate multiple elements' timing, or build a custom animation loop with JavaScript control.

Decision: CSS vs WAAPI vs rAF Loop

If you need... Use... Why
Simple state transitions (hover, toggle) CSS transition Simplest; GPU-composited; browser optimizes
Predefined keyframe animation CSS @keyframes Declarative; no JS; best for looping or self-contained sequences
Dynamic values (from JS variables, user input) WAAPI (element.animate()) CSS cannot use JS values; WAAPI can
Sequence: A completes then B starts WAAPI with .finished promise animation.finished resolves when done; chain with await
Interrupt/cancel a running animation WAAPI .cancel() or .reverse() CSS cannot be interrupted mid-animation and reversed cleanly
Custom physics, particle systems, canvas requestAnimationFrame loop Full control; runs every display frame
Coordinating CSS class adds with timing setTimeout + transitionend event Add class, wait for transition, then do next step

Pattern: WAAPI Basics

// Play an animation imperatively
const anim = element.animate(
  [
    { opacity: 0, transform: 'translateY(20px)' },
    { opacity: 1, transform: 'translateY(0)' },
  ],
  { duration: 300, easing: 'cubic-bezier(0.05, 0.7, 0.1, 1)', fill: 'forwards' }
);

// Cancel before completion
anim.cancel();

// Reverse mid-animation (for hover-out)
anim.reverse();

// Wait for completion before doing next action
await anim.finished;
doNextStep();

Pattern: Promise-Based Sequencing

async function animateSequence(elements) {
  for (const el of elements) {
    await el.animate(
      [{ opacity: 0, transform: 'translateY(16px)' }, { opacity: 1, transform: 'translateY(0)' }],
      { duration: 250, easing: 'ease-out', fill: 'forwards' }
    ).finished;
    // Each element waits for the previous to complete
  }
}

// Parallel with stagger (don't await — start all, then wait for last)
async function animateStagger(elements, staggerMs = 75) {
  const animations = elements.map((el, i) =>
    el.animate(
      [{ opacity: 0 }, { opacity: 1 }],
      { duration: 300, delay: i * staggerMs, fill: 'forwards' }
    )
  );
  await animations.at(-1).finished; // Wait for last to complete
}

Pattern: requestAnimationFrame Custom Loop

class AnimationLoop {
  #rafId = null;
  #startTime = null;

  start(drawFn, duration) {
    this.#startTime = null;
    const tick = (timestamp) => {
      this.#startTime ??= timestamp;
      const elapsed = timestamp - this.#startTime;
      const progress = Math.min(elapsed / duration, 1);
      drawFn(progress);
      if (progress < 1) this.#rafId = requestAnimationFrame(tick);
    };
    this.#rafId = requestAnimationFrame(tick);
  }

  stop() {
    if (this.#rafId) cancelAnimationFrame(this.#rafId);
    this.#rafId = null;
  }
}

Pattern: JS + CSS Coordination

// Add class, then remove after transition completes
function animateIn(element) {
  element.classList.add('is-entering');
  element.addEventListener('transitionend', () => {
    element.classList.remove('is-entering');
    element.classList.add('is-visible');
  }, { once: true });
}

// Cross-fade between two elements
async function crossFade(outEl, inEl) {
  outEl.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 200, fill: 'forwards' });
  await inEl.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 200, fill: 'forwards' }).finished;
  outEl.style.display = 'none';
}

WAAPI Key Properties

Property Values Notes
fill 'none', 'forwards', 'backwards', 'both' 'forwards' keeps end state applied after finish
easing Any CSS easing string Same values as CSS transition-timing-function
iterations Number or Infinity Infinity for infinite loops
direction 'normal', 'reverse', 'alternate' Use 'alternate' for ping-pong loops
delay ms Positive = start delay; use for stagger
composite 'replace', 'add', 'accumulate' 'add' for layering animations on same property

Common Mistakes

  • Using fill: 'forwards' without calling .cancel() later — WAAPI holds a reference to the element; can cause memory leaks in SPAs
  • await animation.finished without error handling.finished rejects if .cancel() is called; wrap in try/catch
  • Using rAF loop for simple transitions — overkill; CSS transitions are simpler and GPU-composited
  • Sequencing with setTimeout guesses — fragile timing that drifts; use .finished promise instead
  • Forgetting prefers-reduced-motion check — WAAPI animations bypass CSS @media (prefers-reduced-motion); check manually
// Always check reduced motion before WAAPI animations
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const duration = prefersReduced ? 0 : 300;
element.animate(keyframes, { duration, fill: 'forwards' });

See Also