Interrupting and Reversing Transitions Mid-Flight
Part of CSS Transitions vs Animations in Core CSS Animation Fundamentals.
The problem
A card lifts on hover over 300ms. A user sweeps the pointer across a row of cards, entering and leaving each one in about 100ms. Depending on how the effect was built, one of three things happens: the cards rise a little and settle back smoothly; the cards rise a little and then take a full 300ms to settle back, lagging behind the pointer; or the cards snap back to rest the instant the pointer leaves, with a visible jump.
The same three behaviours appear in menus closed before they finish opening, toggles pressed twice quickly and accordions clicked repeatedly. Users experience them as polish, sluggishness and glitchiness respectively, and the difference comes down to what the browser does when an effect’s target changes mid-flight.
Root cause analysis: three mechanisms, three interruption rules
Transitions retarget from where they are. When a transitioned property changes while a transition on it is running, the browser cancels the running transition and starts a new one from the current animated value to the new target. Nothing jumps. The running transition fires transitioncancel rather than transitionend.
Reversals are shortened. If the new target is the value the running transition started from — a reversal, the hover-out case — the CSS Transitions specification applies a reversing shortening factor. The new transition’s duration is scaled by how far the interrupted one had progressed, so reversing after 30% of the way takes roughly 30% of the duration. Without that rule, a card that rose 2px would take the full 300ms to fall 2px, which is the “lagging” behaviour. The factor is computed from the eased output progress, so it matches the visual distance covered.
Keyframe animations do not retarget. An animation applied by a :hover rule runs while the rule matches. When it stops matching, the animation is removed, and the element’s style falls straight back to its un-animated value on the next frame. There is no “from the current value” for a removed animation — hence the snap. Swapping to a different animation-name restarts from that animation’s first keyframe, which snaps too.
Decision table
| Effect | Interruptible by the user? | Build it with | Interruption behaviour |
|---|---|---|---|
| Hover lift, focus glow, pressed state | Yes, constantly | Transition | Smooth, shortened reversal |
| Menu or panel open and close | Yes | Transition on the open state | Retargets from current value |
| One-shot celebration, error shake | No, plays once | Keyframe animation | Should not be reversed |
| Looping loader | Only stopped | Keyframe animation | Fade out rather than remove |
| Multi-step sequence that can be cancelled | Yes | Web Animations API | reverse() from current time |
| Scroll-linked progress | Continuously | Scroll-driven animation | Follows the scroll position |
The general rule, developed in when to use a transition versus a keyframe animation, is that anything representing a state the user can leave should be a transition, and anything representing an event should be an animation.
Production code pattern
/* Reversible state: a transition. Interrupting it is always smooth. */
.card {
transition: translate 300ms cubic-bezier(0.2, 0.8, 0.2, 1),
box-shadow 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.card:hover,
.card:focus-within {
translate: 0 -4px;
}
/* Event: a keyframe animation that plays once and is not interrupted by hover. */
.card.is-saved {
animation: saved-pulse 420ms ease-out;
}
@keyframes saved-pulse {
40% { scale: 1.03; }
}
@media (prefers-reduced-motion: reduce) {
.card { transition: box-shadow 150ms linear; }
.card:hover, .card:focus-within { translate: none; }
.card.is-saved { animation: none; }
}
// A multi-step effect that must reverse from wherever it is: Web Animations API.
let drawer;
function toggleDrawer(open) {
if (!drawer) {
drawer = panel.animate(
[{ translate: '100% 0' }, { translate: '0 0' }],
{ duration: 280, easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)', fill: 'both' }
);
drawer.pause();
}
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
drawer.updatePlaybackRate(reduce ? (open ? 50 : -50) : (open ? 1 : -1)); // near-instant when reduced
drawer.play(); // continues from the current time in the new direction
}
// Code that waits for an end must also accept cancellation.
function transitionDone(el, property) {
return new Promise((resolve) => {
const done = (e) => {
if (e.propertyName !== property) return;
el.removeEventListener('transitionend', done);
el.removeEventListener('transitioncancel', done);
resolve(e.type); // 'transitionend' or 'transitioncancel'
};
el.addEventListener('transitionend', done);
el.addEventListener('transitioncancel', done);
});
}
Rendering Impact: composite for
translateandscale; paint forbox-shadow. Interruptions do not change the tier — a retargeted transition runs on the same thread as the original. Replacing the shadow transition with an opacity-faded pseudo-element keeps the whole hover on the compositor.
updatePlaybackRate() followed by play() reverses the drawer from its current position without restarting it, which is the scripted equivalent of a transition’s reversal. Unlike the CSS rule, it does not shorten anything: the remaining distance plays at the normal rate, which is already proportional. The rest of the playback controls are covered in playback rate and reverse.
Verification checklist
Constraints and trade-offs
- The shortening rule applies only to exact reversals; a change to a third value runs the full duration from the current value.
- Changing the transition’s duration or easing in the new state applies to the new transition, which can make reversals feel different from entries — sometimes deliberately.
- A transition cannot express multi-step motion; if an interruptible effect needs keyframes, it needs the Web Animations API.
transitionenddoes not fire for cancelled transitions, so unhandled cancellations leave promises pending forever.- Very fast repeated toggling can accumulate many cancelled transitions and event dispatches; debounce toggles only if profiling shows it matters.
Frequently asked questions
Why does my hover animation snap back when the pointer leaves?
It is a keyframe animation attached to a :hover rule. When the rule stops matching the animation is removed and the element returns immediately to its base style. Use a transition for hover states; transitions reverse from their current value.
What is the reversing shortening factor?
A rule in the CSS Transitions specification: when a transition is interrupted and sent back to its starting value, the new transition’s duration is scaled by how much progress had been made. A quick hover in and out therefore returns quickly instead of taking the full duration.
Does transitionend fire if the transition is interrupted?
No. An interrupted transition fires transitioncancel. The replacement transition fires its own transitionend when it finishes. Code waiting for completion should listen for both.
How do I reverse a keyframe animation smoothly?
Get the animation object with element.getAnimations() or create it with element.animate(), then call reverse() or updatePlaybackRate(-1) and play(). It continues backwards from the current time instead of restarting.
Related
- CSS Transitions vs Animations — the parent topic on the two execution models
- Cancelling and Restarting Without Flicker — restarts that must begin from the first keyframe
- Debugging Transitions That Never Fire — the opposite failure: no transition at all