Interruptible, reversible, composable animations
A real animation almost never gets to finish uninterrupted — the user hovers away, clicks the other tab, drags the panel back — and treating an animation as a value that plays once from start to end, instead of as live state you can query and redirect, is what produces the visible jump when it gets cut off mid-flight.
Interruptible, reversible, composable animations
Every animation example in this module so far has had the luxury of running to completion. A box transitions from left: 0 to left: 200px, the duration elapses, the box arrives, the story ends cleanly. Real interfaces don't work that way. A dropdown starts opening and the user moves the mouse off it before it's halfway there. A card starts flying toward the trash and the user hits undo. A drawer is sliding in when a second tap asks it to slide back out. In a real UI, animations get interrupted constantly, and what happens at the moment of interruption is where most animation code quietly falls apart.
The failure is almost always the same shape: something restarts the animation from its defined start value instead of from wherever the animation actually is right now. The fix requires a change in how you think about what an animation even is — not a fire-and-forget instruction ("play this transition"), but a piece of live, queryable state: a playhead position and a rate of travel, both of which you can read and redirect at any instant. That reframing is what this lesson builds, directly on top of the Animation object the Web Animations API gives you.
The jump: what happens when you get this wrong
Picture a menu that fades and grows in on hover, and shrinks back out on mouse-leave. A tempting, naive implementation:
menu.addEventListener('mouseenter', () => {
menu.animate(
[{ transform: 'scale(0.9)', opacity: 0 }, { transform: 'scale(1)', opacity: 1 }],
{ duration: 200, fill: 'forwards' }
);
});
menu.addEventListener('mouseleave', () => {
menu.animate(
[{ transform: 'scale(1)', opacity: 1 }, { transform: 'scale(0.9)', opacity: 0 }],
{ duration: 200, fill: 'forwards' }
);
});This looks reasonable, and it works fine as long as the user waits for one animation to finish before triggering the other. Move the mouse away 80ms into the opening animation — before it has reached scale(1), while it's sitting at, say, scale(0.96) — and the mouseleave handler fires a brand new animation whose first keyframe is hardcoded to scale(1). The browser doesn't know or care that the element isn't actually at scale(1) yet. It snaps the element to that keyframe's starting value the instant the new animation begins, and then starts animating down from there. The visible result is a small, ugly pop — the menu jumps forward to full size for one frame, then immediately starts shrinking. You asked for smooth in both directions and got a jump stitched into the middle.
This is the core idea worth naming precisely: an animation that can be interrupted correctly has to satisfy value continuity — whatever visual value the element has at the instant of interruption has to be the starting value of whatever happens next. Not the value it would have had if the previous animation had been left alone. Not the value the previous animation was defined to end at or start from. The value it actually, physically has, right now, mid-flight.
How CSS transitions get this right without you doing anything
Here's the useful thing to notice: plain CSS transitions already solve this correctly, and it's worth understanding the mechanism, because it tells you what you need to reproduce by hand whenever you reach for something more powerful. When a CSS transition is interrupted — say, you toggle a class back off while the transition triggered by adding it is still running — the browser does not restart from the property's original declared value. It re-targets the transition: it reads the property's current computed value, at that exact instant, off the render tree, and starts the new transition from there, toward the new target.
.menu {
transform: scale(0.9);
opacity: 0;
transition: transform 0.2s, opacity 0.2s;
}
.menu.open {
transform: scale(1);
opacity: 1;
}menu.addEventListener('mouseenter', () => menu.classList.add('open'));
menu.addEventListener('mouseleave', () => menu.classList.remove('open'));Toggle .open off 80ms into the opening transition, while transform is sitting at some intermediate interpolated value like scale(0.96), and the browser's transition engine reads that live value as the new starting point and transitions smoothly down to scale(0.9) from wherever it actually was. No jump, no pop — because the browser is, mechanically, reading the current computed value off the style system before computing the next transition's start, every single time a transition is triggered or re-triggered. This is a specific, spec-defined behavior of the CSS transition algorithm, not a happy accident: transitions are defined in terms of "the previously computed value" precisely so that interruption is graceful by default.
That's also exactly why the naive WAAPI example above breaks: element.animate() does not do this re-targeting for you. Each call to .animate() creates a brand-new, independent Animation with keyframes you supplied explicitly. Nothing reads the element's current rendered state on your behalf. If you want that same graceful behavior with WAAPI, you have to do the re-targeting yourself — read where things actually are, and start from there.
Doing it explicitly: the Animation object as queryable state
This is where the Web Animations API's Animation object (from the previous lesson) stops being just a handle to stop or pause something, and becomes the thing that makes correct interruption possible in the first place. An in-progress Animation exposes exactly the two numbers you need:
currentTime— a live playhead position, in milliseconds (or a percentage, for a normalized timeline), telling you exactly how far into the effect you currently are. This is not "how far you intended to be by now" — it's the browser's own authoritative answer to "where is this animation, right now."playbackRate— the speed and direction the playhead is advancing.1is forward at normal speed,-1is backward at normal speed,0freezes the playhead in place,2doubles the speed. Changing this doesn't touch the keyframes at all; it just changes how fast (and which way) time flows through them.
Put those two together and "reverse this animation from wherever it is" stops being a hard problem — it's the API's job description:
let current = null;
menu.addEventListener('mouseenter', () => {
current?.cancel(); // drop any in-flight animation; its effect is discarded
current = menu.animate(
[{ transform: 'scale(0.9)', opacity: 0 }, { transform: 'scale(1)', opacity: 1 }],
{ duration: 200, fill: 'forwards', easing: 'ease-out' }
);
});
menu.addEventListener('mouseleave', () => {
if (current) {
current.reverse(); // continues from currentTime, playing the SAME keyframes backward
}
});animation.reverse() is the API's built-in answer to exactly the scenario that broke the naive version. It doesn't restart the animation from the "end" keyframe as a new start value — it flips playbackRate from 1 to -1 and lets the existing playhead keep moving through the same keyframe timeline, just in the opposite direction, from whatever currentTime it was already at. If the opening animation was 80ms into its 200ms duration when you called reverse(), the animation is now playing backward starting at the 80ms mark, and it will take 80ms to get back to the start — not 200. Nothing about the element's rendered state jumps, because nothing about where the playhead is changed. Only the direction it's moving changed.
Setting playbackRate directly gives you the same mechanism with more control:
// Equivalent to reverse(), but explicit, and you can pick any rate
current.playbackRate = -1;
// Slow-motion reverse, e.g. for a "let go gently" gesture
current.playbackRate = -0.4;
// Resume forward from wherever it currently is
current.playbackRate = 1;And when you need to hand an in-progress animation off to something else entirely — say, a drag gesture takes over mid-transition and now the user's pointer position should drive the value instead of the timeline — currentTime is the read you need to make that handoff seamless:
const progressMs = current.currentTime; // read the live playhead, a real number
current.pause();
// now drive the effect manually from progressMs onward, e.g. from pointer input,
// with the guarantee that you're starting from the animation's actual positionIn every one of these, the shape of the fix is identical: don't declare where the animation should start. Ask the Animation object where it currently is, and build the next step from that answer. That's value continuity, made explicit instead of implicit.
Composable animations: composite: 'add'
Reversing and redirecting solves interruption when one animation is replacing another on the same property. A related but distinct problem shows up when you want two independent animations to affect the same property at the same time — for example, a card that has a continuous idle "breathing" scale animation running, and which should also scale up slightly in response to a hover, without the hover animation clobbering the idle one or fighting it for ownership of transform.
By default, WAAPI's compositing behavior is replace: the animation that was added most recently (or that's highest in the effect stack) simply overwrites the property's value for the frames it's active, and whatever the other animation was contributing is discarded for that frame. That's correct when animations are meant to supersede each other — it's wrong when they're meant to combine.
Setting composite: 'add' on an animation changes this: instead of replacing the property's current value, the animation's contribution is composed on top of it — for transforms specifically, the two transform lists are concatenated and applied in sequence, so translations, scales, and rotations from separate animations all take effect together rather than one silently overriding the other.
// A continuous idle animation, always running
const breathe = card.animate(
[{ transform: 'scale(1)' }, { transform: 'scale(1.03)' }],
{ duration: 1200, iterations: Infinity, direction: 'alternate', easing: 'ease-in-out' }
);
// A hover animation that ADDS its own scale on top of whatever `breathe`
// is contributing that frame, instead of overwriting it
card.addEventListener('mouseenter', () => {
card.animate(
[{ transform: 'scale(1)' }, { transform: 'scale(1.08)' }],
{ duration: 150, fill: 'forwards', composite: 'add' }
);
});Without composite: 'add', starting the hover animation would simply take over transform for its duration, and the idle breathing animation's own frames would be discarded while the hover animation is active — visually, the breathing would freeze the instant you hover, then resume abruptly when the hover animation ends. With it, both are live at once: at any given frame, the browser evaluates both animations' current keyframe values and combines them, so the hover-scale rides on top of whatever phase the breathing animation happens to be in. Two independent, ongoing pieces of motion, driven by two independent triggers, neither one having to know about or coordinate with the other. That's the payoff of treating an animation as composable state rather than as an exclusive, all-or-nothing claim on a property.
The underlying idea
Everything in this lesson is one idea applied three ways. An animation is not a fire-and-forget command; it's a live object with a position (currentTime), a rate (playbackRate), and a contribution mode (composite), all of which you can read and change at any point while it's running. CSS transitions get value continuity for free because the browser re-targets from the current computed value under the hood. WAAPI gives you the same correctness explicitly, because it exposes the playhead and the rate as things you can query and set directly, rather than hiding them. And composite: 'add' extends the same idea from redirecting one animation to layering several — because if an animation is just state contributing a value, there's no reason only one of them gets to contribute at a time.
The next lesson, FLIP: animating layout changes without paying for layout, uses this same "read the actual current state, don't assume it" discipline for a different problem: instead of reading an animation's live playhead, it reads an element's live layout position with getBoundingClientRect(), to make a layout change look animated without ever animating layout itself.
Go deeper
- MDN — Animation.currentTime — The precise semantics of the playhead: how it's defined relative to the timeline, and how reading/writing it interacts with play state.
- MDN — Animation.reverse() — Confirms reverse() operates by flipping playbackRate and continuing from currentTime, not by restarting the effect from a declared endpoint.
- MDN — KeyframeEffect.composite — The compositing modes (replace, add, accumulate) and exactly how 'add' combines transform lists instead of overwriting them.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- Walk through, mechanically, why the naive re-animate-on-mouseleave example produces a visible pop when interrupted mid-animation.
- What is 'value continuity,' and why is it the actual requirement an interruptible animation has to satisfy — not merely 'looking smooth'?
- How does a CSS transition achieve graceful interruption without any JavaScript intervention? What specifically does the browser read before starting the new transition?
- Explain the difference between calling animation.reverse() and creating a brand-new Animation with the keyframes swapped. Why does only one of them avoid a jump?
- What do currentTime and playbackRate each let you do that the other doesn't?
- Why does composite: 'add' require concatenating transform lists rather than just summing numbers, and what would go wrong with the default 'replace' behavior in the breathing-card example?
- A drag gesture needs to take over an in-progress WAAPI animation. Which property do you read first, and why does reading it before pausing matter?