Handling animationend and transitionend Reliably
Part of Animation State Management in Core CSS Animation Fundamentals.
The problem
A modal removes itself from the DOM when its closing animation ends. Most of the time it works. Occasionally the modal never goes away and blocks the page. Other times it disappears before the animation is visible. Under reduced motion it never closes at all. In a different component, a transitionend handler runs three times for a single state change.
Every one of these comes from treating end events as a guaranteed, single notification. They are neither.
Root cause analysis: six ways end events mislead
They bubble. animationend and transitionend bubble up the tree. A button inside the modal finishing its hover transition fires transitionend on the modal too. A handler that does not check event.target reacts to children’s animations.
Transitions fire once per property. transition: opacity 200ms, transform 200ms produces two transitionend events. Shorthands expand further: transitioning border produces events for every longhand that changed.
Interrupted effects do not end. If the transition is interrupted or the animation is removed before completion — the class is toggled back, the element gets display: none — the end event never fires. transitioncancel or animationcancel fires instead.
Nothing fires when nothing runs. If the computed value did not change, no transition starts. If a reduced-motion rule sets animation: none, no animation starts. A promise waiting for an end event waits forever.
Zero duration still fires, sometimes. A transition with 0s duration and no delay does not run at all; an animation with 0s duration does run and fires animationstart and animationend in the same frame. Code that disables motion with zero durations behaves differently for the two.
Hidden documents and detached elements. Animations on elements removed from the document are cancelled. In background tabs, timing can be throttled so end events arrive much later than expected.
Step-by-step resolution
Production code pattern
/**
* Resolves when the element's current animations/transitions for `props`
* have finished or been cancelled. Never hangs.
*/
export async function motionSettled(el, { props = null, timeout = 1000 } = {}) {
// Give style a chance to start newly triggered transitions.
await new Promise(requestAnimationFrame);
const running = el.getAnimations().filter((a) => {
if (!props) return true;
const name = a.transitionProperty ?? a.animationName;
return props.includes(name);
});
if (running.length === 0) return 'none'; // nothing ran: reduced motion, no-op, zero duration
const settled = Promise.allSettled(running.map((a) => a.finished))
.then((results) => results.every((r) => r.status === 'fulfilled') ? 'finished' : 'cancelled');
const timer = new Promise((resolve) => setTimeout(() => resolve('timeout'), timeout));
return Promise.race([settled, timer]);
}
// Usage: close a modal and remove it only after its exit settles.
async function closeModal(modal) {
modal.dataset.state = 'closing';
const outcome = await motionSettled(modal, { props: ['modal-out'] });
if (modal.dataset.state === 'closing') modal.remove(); // unless it was reopened meanwhile
return outcome;
}
.modal[data-state="closing"] { animation: modal-out 200ms ease-in forwards; }
@keyframes modal-out { to { opacity: 0; scale: 0.96; } }
@media (prefers-reduced-motion: reduce) {
.modal[data-state="closing"] { animation: none; } /* helper resolves 'none' immediately */
}
Rendering Impact: none added. The helper inspects animation objects the browser already maintains; the waiting frame ensures newly triggered transitions are registered before they are looked up.
The requestAnimationFrame wait at the start matters for transitions. Setting a class queues a style change; the transition object only exists after style is recalculated. Calling getAnimations() synchronously right after the class change forces style and usually finds it, but waiting one frame avoids forcing an extra style recalculation in the middle of your handler.
The final check — if (modal.dataset.state === 'closing') — handles the race that end-event code nearly always gets wrong: the user reopened the modal while it was closing. The exit animation was cancelled, the helper resolved cancelled, and removing the element now would delete an open modal.
Verification checklist
Constraints and trade-offs
Animation.finishedrejects on cancel; always useallSettledor catch.- A timeout that is too short resolves before slow animations finish; base it on the longest declared duration.
getAnimations()does not include animations on pseudo-elements unless queried withsubtree: truefrom the parent.- Waiting one frame delays completion slightly even when nothing runs.
- Filtering by name ties script to CSS identifiers; keep names in shared constants.
Frequently asked questions
Why does transitionend fire multiple times?
It fires once per transitioned property, and it bubbles from children. Check event.target and event.propertyName and act only on the one you care about.
Why does my animationend handler never run?
Either the animation did not run — reduced motion, animation: none, or no style change — or it was cancelled before finishing. Listen for animationcancel too, and check getAnimations() first.
Is Animation.finished better than end events?
Yes where available. It is tied to a specific animation, settles on completion, rejects on cancellation and does not bubble.
Should completion code use setTimeout with the duration instead?
Only as a safety net. Durations change, reduced motion removes animations and background tabs delay timers; relying on the timeout alone drifts out of sync.
Related
- Animation State Management — the parent topic
- Animation State Machines with Data Attributes — where these events advance state
- The Animation finished Promise and Cancellation — the promise-based completion model