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.

What fires in each situationOnly a helper that listens for both end and cancel, and checks for running animations first, covers every row.What fires in each situationtransitionendtransitioncancelNothingTransitioncompletesOnce per propertyTransition reversedmid-flightYesElement set todisplay: noneYesValue unchangedNo event at allReduced motion:transition noneNo event at all
Only a helper that listens for both end and cancel, and checks for running animations first, covers every row.

Step-by-step resolution

A completion helper that cannot hangCheck what is running first; then listen for every way it can stop.A completion helper that cannot hang1After changing state, read element.getAnimations() for matching effects.Knows whether anything is running2If none match, resolve immediately.Reduced motion and no-op changes do not hang3Otherwise await Promise.allSettled on each animation's finished promise.Cancellation rejects instead of hanging4Where getAnimations is unavailable, listen for end and cancel events filtered by target andproperty.Event fallback5Add a timeout of the longest duration plus a margin.Background tabs cannot stall the flow6Report whether it finished or was cancelled so callers can decide.
Check what is running first; then listen for every way it can stop.

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.

Naive end-event handler against the settled helperThe helper returns an outcome instead of assuming the event arrives.Naive end-event handler against the settled helperaddEventListener('animationend', remove)Children's animations trigger itNever fires under reduced motionFires after a reopen races the closeHangs or removes wronglymotionSettled + state checkFilters by animation nameResolves 'none' when nothing runsCaller checks state before actingAlways settles correctly
The helper returns an outcome instead of assuming the event arrives.

Verification checklist

Constraints and trade-offs

  • Animation.finished rejects on cancel; always use allSettled or 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 with subtree: true from 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.