Which events does a CSS keyframe animation dispatch, and when does animationend fail to fire?
answer
- four events, all of them bubble
- between iterations, not around them
- infinite never ends
- cancelled sends a different event
- branch on the animation name
basics
~20 sA CSS animation dispatches animationstart, animationiteration between iterations, animationend on completion, and animationcancel if it stops early. animationend never fires when animation-iteration-count is infinite, or when the animation is removed or the element stops being rendered.
solid answer
~40 sFour events fire on the animated element and they bubble: `animationstart` when the animation begins (after the delay), `animationiteration` at each boundary *between* iterations, `animationend` when it completes, and `animationcancel` when it stops before completing — because `animation-name` changed, the element became un-rendered, or the animation was cancelled from script. Two cases mean you will wait forever for `animationend`: `animation-iteration-count: infinite`, which never completes, and anything that cancels the animation, which sends `animationcancel` instead. Each event carries `animationName`, `elapsedTime` and `pseudoElement`, and a comma-separated list of animations fires one set of events per animation — so a handler that reacts to any `animationend` on the element will run more than once. Check `event.animationName` before acting, and be ready to handle the cancel case in cleanup code.
code
javascript · 10 linesconst el = document.querySelector('.toast');
const finish = (event) => {
if (event.animationName !== 'toast-out') return;
el.remove();
};
// Both terminal events, one idempotent handler.
el.addEventListener('animationend', finish);
el.addEventListener('animationcancel', finish);go deeper
Know the three main event names — animationstart, animationiteration, animationend — and that an infinite animation never reaches the end event.
Explain that animationiteration marks the boundaries between iterations, that animationcancel replaces animationend when an animation is interrupted, and what animationName and elapsedTime carry.
Show the production habit: filter on animationName because layered animations each fire, wire cleanup to both terminal events so an interruption cannot leak state, and reach for the finished promise when one code path is cleaner.
Own where animation-completion state lives at all — a system whose correctness depends on an event that an interruption can silently withhold is fragile, and it is worth deciding once whether motion may drive application state.
## The four events A running keyframe animation reports its lifecycle through DOM events dispatched at the element (or at the originating element, for animations on `::before` / `::after`). All four bubble, so a delegated listener on an ancestor sees them. - **`animationstart`** — fires when the active interval begins, i.e. after `animation-delay` has elapsed. - **`animationiteration`** — fires at the boundary *between* two iterations. It does not fire before the first iteration nor after the last, so an animation with `animation-iteration-count: 3` produces exactly two of them, and one with the default count of `1` produces none. - **`animationend`** — fires once when the animation completes normally. - **`animationcancel`** — fires when the animation stops before completing. ## What each event carries Every one is an `AnimationEvent` with three extra properties: `animationName` (the `@keyframes` name, which is how you tell layered animations apart), `elapsedTime` (seconds the animation has been running, excluding paused time), and `pseudoElement` (`"::before"`, `"::after"` or the empty string). `elapsedTime` has a useful quirk with a negative delay. `animation: float 4s -2s` starts immediately with its clock already two seconds in, and the `animationstart` event fires straight away with `elapsedTime` equal to `2` — the absolute value of the delay — rather than `0`. ```js el.addEventListener('animationstart', (e) => { console.log(e.animationName, e.elapsedTime, e.pseudoElement); }); ``` ## When animationend does not come This is the practical half of the question, because code that cleans up after an animation is usually the code that breaks. **Infinite animations.** `animation-iteration-count: infinite` never completes, so `animationend` never fires. Only `animationstart` and a stream of `animationiteration` events arrive. A cleanup handler waiting on `animationend` for a spinner waits forever. **Cancellation.** If the animation is removed before completing — `animation-name` changes or goes to `none`, the class carrying it is removed, the element becomes un-rendered (`display: none`), the element is detached from the document, or script cancels it — the browser dispatches `animationcancel` instead of `animationend`. Any teardown that only listens for `animationend` leaks: the class is never removed, the promise never settles, the element stays in its half-animated state. **A zero duration.** With `animation-duration: 0s` the animation still starts and ends, so both events fire, but they arrive in the same frame — which is fine for handlers and confusing for anyone timing things by hand. ## Layered animations fire layered events A comma-separated animation list is several independent animations on one element, each with its own lifecycle: ```css .hero { animation: fade-in 400ms ease-out both, float 6s ease-in-out infinite alternate; } ``` Here `animationend` fires once, for `fade-in`, at 400ms, and never for `float`. A naive handler that removes the element on any `animationend` would fire at the wrong moment for the wrong animation. Always branch on `animationName`: ```js el.addEventListener('animationend', (e) => { if (e.animationName !== 'fade-in') return; el.classList.add('is-visible'); }); ``` If two animations in the list touch the same property, the one later in the list wins for that property — the events do not tell you that, so keep layered animations on disjoint properties where you can. ## The robust cleanup pattern When an animation's completion drives real state — removing a toast, unmounting a menu, enabling a button — write the handler so that both terminal events do the teardown, and make the teardown idempotent: ```js const finish = (e) => { if (e.animationName !== 'toast-out') return; el.remove(); }; el.addEventListener('animationend', finish); el.addEventListener('animationcancel', finish); ``` An alternative that collapses all of this is the animation object's `finished` promise from `element.getAnimations()`, which resolves on completion and rejects on cancel — one code path, both outcomes, no name filtering. ## What these events are not for They do not tell you about individual keyframes; there is no "keyframe reached" event. If you need to act at 40% of a motion, either split the animation into two animations with a delay, or read the animation's `currentTime`. And an animation that never starts — a duration of `0s` with a name that never bound, or a keyframes name eaten by the shorthand grammar — produces no events at all, which is itself the most useful diagnostic that something is wrong with the declaration.
- How many animationiteration events fire for animation: spin 1s 3?Two. The event marks the boundary *between* iterations, not the start or end of the animation, so an N-iteration animation fires N−1 of them. With the default count of 1 it never fires at all, and with `infinite` it fires once per second indefinitely while `animationend` never arrives.
- An element has two animations in its animation list. What does an animationend handler see?One `animationend` per animation that completes, each carrying its own `animationName` and firing at its own time. A handler that acts on any end event will therefore run more than once and possibly at the wrong moment. Filter on `event.animationName`, or use the specific animation object's `finished` promise from `element.getAnimations()`.
- Why should cleanup code listen for animationcancel as well as animationend?Because an animation that is interrupted — the class removed, the element hidden or detached, the name changed — dispatches `animationcancel` and never `animationend`. Teardown wired only to the end event silently leaks: the state class is never removed, the promise never settles, the node stays in the DOM. Wire both to one idempotent handler.
saying these in an interview costs you the question
- Expecting animationend from an infinite animation
- Thinking animationiteration fires once per iteration
- Assuming these events do not bubble
- Ignoring animationName with layered animations
- Believing each keyframe dispatches its own event