TechEarl

Run JavaScript When a CSS Animation or Transition Ends

Fire a JavaScript callback when a CSS transition or animation finishes, using the transitionend and animationend events. The propertyName filtering, the bubbling trap, the cases where the event never fires, and the modern Web Animations API promise alternative.

Ishan Karunaratne⏱️ 7 min readUpdated
Share thisCopied
Run a JavaScript callback when a CSS transition or animation ends with transitionend and animationend, filter by propertyName, handle bubbling, and use the Web Animations API finished promise.

The way to run JavaScript when a CSS transition finishes is the transitionend event; for a keyframe animation it is animationend. Both are plain DOM events you bind with addEventListener, no prefixes, no libraries:

javascript
const el = document.querySelector(".panel");

el.addEventListener("transitionend", () => {
  el.remove();
});

That is the whole idea. The reason this page exists is that the four lines above are quietly wrong in ways that bite you in production: the event fires once per animated property, it bubbles up from child elements, and in several common situations it never fires at all. Get those three right and you have a callback you can trust.

transitionend fires once per property, not once per transition

If you transition opacity and transform together, transitionend fires twice, once for each property. Your callback runs twice. Filter on event.propertyName to act on the one you care about:

javascript
el.addEventListener("transitionend", (event) => {
  if (event.propertyName !== "opacity") return;
  el.remove();
});

animationend has the same shape with event.animationName (the value of your @keyframes name), so a single element running two named animations can fire animationend twice. Filter the same way.

It bubbles, so a child's transition can trigger your handler

This is the subtle one. transitionend and animationend both bubble. If you attach the listener to a container and any descendant has its own transition, that descendant's event travels up and runs your container's callback. You think you are reacting to the panel sliding closed; you are actually reacting to a button inside it changing color on hover.

Guard with event.target:

javascript
el.addEventListener("transitionend", (event) => {
  if (event.target !== el) return;        // ignore bubbled child events
  if (event.propertyName !== "transform") return;
  done();
});

Both checks earn their place: event.target filters out children, event.propertyName filters out the other properties on this element.

The cases where it never fires

The hard-won lesson is that transitionend is not guaranteed. The browser only fires it when a transition actually ran and completed. It does not fire if:

  • The value never changed. Setting a property to the value it already had is a no-op, so no transition starts and no end event arrives. Toggling a class that resolves to the same computed value is the classic version of this.
  • The transition is interrupted. Setting display: none mid-transition (or removing the element) cancels it. You get transitioncancel, not transitionend. Same for an animation: animationcancel, not animationend.
  • There was no transition to begin with. No transition property, a zero duration, or a prefers-reduced-motion rule that stripped the animation means the visual change happens instantly with no event.

If your code waits for transitionend before, say, unmounting a component, any of these leaves it waiting forever. The defensive pattern is a timeout fallback that fires the callback if the event has not arrived shortly after the transition's own duration:

javascript
function te_after_transition(el, prop, done) {
  let called = false;
  const finish = () => {
    if (called) return;
    called = true;
    el.removeEventListener("transitionend", onEnd);
    done();
  };
  const onEnd = (event) => {
    if (event.target === el && event.propertyName === prop) finish();
  };
  el.addEventListener("transitionend", onEnd);

  // Fallback: read the real duration, add a margin, fire anyway.
  const ms = parseFloat(getComputedStyle(el).transitionDuration) * 1000;
  setTimeout(finish, ms + 50);
}

The called flag means whichever path wins (real event or timeout), done runs exactly once. One caveat on the duration read: when an element sets multiple transition durations, getComputedStyle(el).transitionDuration returns a comma-separated list ("0.3s, 0.5s"), and parseFloat takes only the first. If your transitions have different durations, split on the comma and use the longest before adding the margin.

Auto-removing the listener with the once option

If you only want the callback once and then gone, you do not need a manual removeEventListener. Pass { once: true } and the browser detaches the listener after it fires:

javascript
el.addEventListener(
  "animationend",
  () => el.classList.remove("is-animating"),
  { once: true }
);

Two caveats. { once: true } removes the listener after the first fire, so on a multi-property transition it removes after the first property ends, which may not be the property you wanted. And because the listener never runs in the does-not-fire cases above, { once: true } does not clean itself up if the event never comes; pair it with the same timeout fallback if that matters.

The modern alternative: the Web Animations API finished promise

For animations you create in JavaScript, you can skip events entirely. Element.animate() and the Web Animations API give you an Animation object whose finished property is a promise that resolves when the animation completes (and rejects if it is cancelled). You await it:

javascript
async function te_slide_out(el) {
  const animation = el.animate(
    [{ opacity: 1 }, { opacity: 0 }],
    { duration: 300, easing: "ease" }
  );
  await animation.finished;   // resolves on finish, throws on cancel
  el.remove();
}

This is cleaner than the event dance: one property, no bubbling, no per-property double-fire, and a real promise you can await, chain, or wrap in try/catch to handle cancellation. The catch is that it covers animations created through the API. For a CSS transition or a @keyframes animation declared in your stylesheet, you can still reach it: Element.getAnimations() returns every Animation affecting the element, including CSS-declared ones, so you can grab them and await their finished promises:

javascript
// Wait for whatever CSS transitions/animations are currently running on el.
async function te_wait_for_css(el) {
  await Promise.all(el.getAnimations().map((a) => a.finished));
}

The same "does it actually run" caveat applies: if a transition reduces to a no-op (no change, reduced motion), getAnimations() returns nothing for it and the promise resolves immediately, which is usually the behavior you want.

Which to reach for

Listen for transitionend or animationend when the animation lives in CSS and you are reacting to it (filter propertyName/animationName, check event.target, keep a timeout fallback). Reach for Element.animate() and await animation.finished when JavaScript is driving the animation anyway. The events bubble and double-fire; the promise does not. And whichever you pick, respect prefers-reduced-motion: a reader who has reduced motion on will often get no event and no animation at all, which is exactly the does-not-fire case you have to handle gracefully.

See also

Sources

Authoritative references this article was fact-checked against.

TagstransitionendanimationendCSS animationCSS transitionWeb Animations APIJavaScriptanimation callback

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

Bash Arrays: Indexed, Associative, and Iteration Patterns

Bash array reference: indexed and associative declaration, the [@] vs [*] quoting gotcha, iterating values and indexes, appending, slicing, deleting, mapfile/readarray for reading lines, and the macOS Bash 3.2 vs Linux Bash 4+ differences.