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.
Step-by-step resolution
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()andupdatePlaybackRate()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 withiterations: Infinityis meaningless — there is no end to seek to.- Assigning to
playbackRatedirectly produces a visible jump because the current time is rescaled; useupdatePlaybackRate()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
currentTimewhile 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.
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.
Related
- Web Animations API Integration — the parent guide covering the rest of the API surface
- Animation State Management — the state machine that decides when to reverse rather than restart
- When to Use Transition vs Keyframe Animation — why transitions get retargeting for free