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:
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:
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:
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: nonemid-transition (or removing the element) cancels it. You gettransitioncancel, nottransitionend. Same for an animation:animationcancel, notanimationend. - There was no transition to begin with. No
transitionproperty, a zero duration, or aprefers-reduced-motionrule 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:
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:
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:
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:
// 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
- prefers-reduced-motion: respect a reader's motion settings: why some users get no transition and no end event, and how to design for it.
- Read and write CSS variables from JavaScript: drive the values your transitions animate toward, then react when they land.
- The CSS :has() selector: styling a parent from its children: another case where a child's state reaches up to its container, handled in CSS rather than in an event listener.
Sources
Authoritative references this article was fact-checked against.
- Element: transitionend event (MDN Web Docs)developer.mozilla.org
- Element: animationend event (MDN Web Docs)developer.mozilla.org
- Animation: finished property (MDN Web Docs)developer.mozilla.org
- Element: getAnimations() method (MDN Web Docs)developer.mozilla.org





