Controlling Playback Rate and Reverse with WAAPI

Part of Web Animations API Integration in Core CSS Animation Fundamentals.

The problem

A user opens a menu, changes their mind, and closes it before the open animation has finished. With CSS keyframes there are two outcomes and neither is good: the close animation restarts from the fully-open state, so the menu visibly jumps open before closing, or the class is removed and the menu vanishes with no exit at all.

Transitions handle this correctly — they retarget from the current computed value — but keyframe animations do not, and neither does any effect with more than two stops. The Web Animations API is the only mechanism that can take an effect that is already running and change what it does next.

Root cause: an animation has a current time, and CSS cannot address it

Every animation is a function from a current time to a set of property values. CSS gives you one control over that time — animation-play-state, which stops and starts it — and no way to read it, set it, or run it backwards.

The API exposes the time directly. currentTime is readable and writable; playbackRate scales how fast it advances and may be negative; reverse() is the convenience form of “negate the rate and keep going”. Because these operate on the time rather than on the effect, none of them restart anything: the keyframes are unchanged, only the direction and speed of travel through them.

The important consequence for interaction design is that an interrupted effect can retarget. Reversing at 40% travels back through the same 40%, which takes 40% of the duration and looks exactly like undoing the gesture — because it is.

What an interruption can do to a running effectOnly the reverse path preserves the position the user is looking at.What an interruption can do to a running effectRunning forwardReversingPausedCancelledreverse()reverse() againpause()play()cancel()
Only the reverse path preserves the position the user is looking at.

Step-by-step resolution

Making a menu interruptibleOne animation object per element, reused rather than recreated, is the whole technique.Making a menu interruptible1Create the open animation once and store it on the element, paused at timezero.One object owns the whole open/close range2On open, set the playback rate positive and play; on close, callreverse().Interrupting mid-flight travels back from the current position3Read playState before acting so a finished animation is seeked rather thanreplayed.No jump when reversing an already-finished effect4Await finished and treat AbortError as a normal cancellation.Teardown mid-animation does not throw5Under reduced motion, set the playback rate high enough that the travel isimperceptible, or skip to the end state directly.
One animation object per element, reused rather than recreated, is the whole technique.

Production code pattern

// Intent: one long-lived animation per menu, driven forwards and backwards,
// so an interrupted open becomes a close from exactly where it got to.
function createMenuMotion(menu) {
  const animation = menu.animate(
    [
      { opacity: 0, translate: '0 -8px' },
      { opacity: 1, translate: '0 0' },
    ],
    { duration: 220, easing: 'cubic-bezier(0, 0, 0.2, 1)', fill: 'both' }
  );
  animation.pause();               // created, not playing: we own the clock
  animation.currentTime = 0;

  return {
    open() {
      // Already open or opening? Nothing to do — do not restart.
      if (animation.playbackRate > 0 && animation.playState !== 'paused') return;
      animation.playbackRate = 1;
      animation.play();
      return animation.finished.catch(() => {});
    },
    close() {
      // reverse() negates the rate; a finished effect seeks to the end first,
      // so a fully-open menu closes from fully open and a half-open one from half.
      animation.reverse();
      return animation.finished.catch(() => {});
    },
    // Called when the motion preference changes mid-session.
    instant() {
      animation.updatePlaybackRate(24);   // smooth retarget, no visual jump
    },
  };
}
/* Accessibility gate: the declarative half of the same component. The script
   asks for a high playback rate under reduce; the resting styles guarantee the
   menu is legible even if no animation runs at all. */
.menu { opacity: 1; translate: 0 0; }

@media (prefers-reduced-motion: reduce) {
  .menu { transition: opacity 100ms linear; }
}

Rendering Impact: opacity + translate — composite only. reverse(), play() and updatePlaybackRate() are main-thread bookkeeping calls that adjust the timeline; they do not rebuild the effect or force a style recalculation.

The instant() method is worth dwelling on. When the user turns on reduced motion while a menu is mid-animation, cancelling would leave the element wherever it happened to be. Raising the playback rate finishes the journey in a couple of frames, so the end state is reached deterministically — the same outcome the reduced path would have produced, just arrived at from where the interface already was.

Verification checklist

Constraints and trade-offs

  • A long-lived animation object holds a reference to its element, so components that create one per instance must cancel it on teardown.
  • reverse() on an animation with iterations: Infinity is meaningless — there is no end to seek to.
  • Assigning to playbackRate directly produces a visible jump because the current time is rescaled; use updatePlaybackRate() for anything on screen.
  • Negative playback rates run the easing backwards too, so an asymmetric curve looks different in reverse. Where the return journey needs its own shape, two effects are clearer than one.
  • Seeking with currentTime while an effect is on the compositor requires a round trip to synchronise, so scrubbing every frame from a pointer handler reintroduces main-thread work.

Frequently asked questions

What is the difference between playbackRate and updatePlaybackRate()?

Assigning to playbackRate applies the new rate immediately, which shifts the effect’s current time and produces a visible jump. updatePlaybackRate() schedules the change so the animation continues from exactly where it is, which is what you want for anything the user can see.

Does reverse() restart the animation?

No. It negates the playback rate and, if the animation has already finished, seeks to the end before playing backwards. An effect interrupted halfway travels back from that halfway point, which is why a reversible transition feels responsive and a restarted one does not.

Can I control a CSS animation this way?

Yes. getAnimations() returns CSSAnimation and CSSTransition objects alongside scripted ones, and they expose the same playback controls. Declaring the keyframes in CSS and controlling playback from script is often the cleanest split.


Scrubbing an animation from a gesture

The controls above cover discrete commands — play, pause, reverse. A drag-to-reveal or swipe-to-dismiss interaction needs something different: the animation’s position driven continuously by the pointer.

currentTime supports it directly. Pause the animation, then write a time proportional to the gesture’s progress on each pointer move, and release by setting a playback rate and calling play() so the animation completes or rewinds by itself from wherever the finger left it.

Driving an effect from a pointerThe animation object is the state; the gesture only writes to its clock.Driving an effect from a pointer1Create the effect paused, spanning the full gesture range.Duration maps to the whole travel2On pointermove, set currentTime to progress multiplied by the duration.The element tracks the finger exactly3On pointerup, decide the destination from position and velocity.Past halfway or moving fast means complete4Set playbackRate positive or negative and call play().The animation finishes the journey on the compositor
The animation object is the state; the gesture only writes to its clock.

Two cautions. Writing currentTime on every pointer move re-synchronises a compositor animation with the main thread, so the scrub itself costs more than a normal compositor animation — acceptable for the duration of a gesture, not for an ambient effect. And a scrub with no release logic leaves the element mid-animation if the pointer is cancelled; always handle pointercancel alongside pointerup.