Shots in this scene
Transition Events and Their Traps
In one line
Four events report a transition's life: run, start, end and cancel, one set per property. Code that waits for transitionend will wait forever when the transition never starts, is cancelled, or the element is hidden, so every wait needs a way out.
Think of it like this
You ask a courier to text you when the parcel arrives. If the parcel is never sent, or the courier's van is recalled, you will wait for a text that never comes. The sensible plan is to say: text me on arrival, text me if it is cancelled, and I will ring them myself after an hour. Transition events are the texts. The timeout is the phone call.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- transitionrun
- The event fired when a transition has been created, even if it still has a delay to wait out.
- transitionstart
- The event fired when the delay is over and the value begins to change.
- transitionend
- The event fired when a transition has completed. It carries propertyName and elapsedTime.
- transitioncancel
- The event fired when a transition is stopped before it finishes, for example when the element is hidden.
Step by step
01Count the events
Run "One transition, four properties". The console prints three lines: run, start and end each fired four times, for color (from the child), opacity, transform and width.
02Spot the bubbling
The color entry says it came from the child. The card's listener heard a transition that did not happen on the card, because events bubble.
03Run the traps
Run "When transitionend never comes". Each line is a separate element. The normal case ends. nothing changes fires nothing and the helper times out. The other three fire run, start, cancel.
04Read the helper
In the same example the right column shows what afterTransition returned: ended, timeout or cancelled. The caller can always continue, whichever it is.
Try it yourself
- 1
Give the card a delay
In "One transition, four properties" change the card's
transitiontoopacity 200ms 300ms, transform 300ms, width 300ms. The counts stay at 4, but if you freeze the page at 100 ms the opacity track is hatched (waiting) and the others are moving.transitionrunfor opacity has already fired.transitionstarthas not. - 2
Cancel on purpose
In "When transitionend never comes" change the
display: noneinterruption toel.style.opacity = "0.3"(the value it was already heading to) and see the transition finish asended, notcancelled: changing to the same target does not cancel it.
Code & diagrams
The card's listener hears the card's three transitions and the child's one. The script waits for everything to finish, prints the counts and resets.
Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.
Console
Nothing logged yet.Expected console output
transitionrun x4: color (from the child), opacity, transform, width
transitionstart x4: color (from the child), opacity, transform, width
transitionend x4: color (from the child), opacity, transform, widthFive elements, one after another. The middle column is the events heard on the element, and the right column is what the promise helper returned after the case.
Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.
Console
Nothing logged yet.Expected console output
normal run, start, end helper: ended
nothing changes helper: timeout
display: none run, start, cancel helper: cancelled
transition: none run, start, cancel helper: cancelled
element removed run, start, cancel helper: cancelledBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Remove an element on transitionend when it is hidden first
In your own page: panel.addEventListener("transitionend", () => panel.remove()); panel.classList.add("closing"); where the panel has already been hidden with display: none.
Myth vs fact
Myth
transitionend always fires.
Fact
It fires only for a transition that completes. A hidden, removed, retargeted-to-the-same-value or never-created transition gives a cancel or nothing at all.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
transitioncancelalso fires when a running transition is replaced by a new one for the same property (a retarget or a reverse, Topic 3.4). A handler that treats every cancel as a close will close panels the user had already reopened. - ▸
Under
prefers-reduced-motionyou may set durations to0s, and then no events fire at all. Code that depends ontransitionendto finish must have the fallback (or checkmatchMediafirst), or reduced-motion users get a stuck interface. Phase 7 builds this in.
Remember this
- 1
The problem this solves: often code must do something when a transition ends: remove an element, focus a field, start the next step. If it waits for
transitionendand that event never fires, a modal stays in the DOM, a button stays disabled, a list never updates. The failure is silent, and it only happens in the cases you did not test. - 2
The four events.
transitionrunfires when the transition is created, before any delay.transitionstartfires when the delay is over and the change begins.transitionendfires when the transition completes.transitioncancelfires when it is stopped before completing. In a measurement withtransition: opacity 200ms linear 300ms, run came in the first frame, start about 300 ms later, and end about 200 ms after that, withelapsedTime0.2(seconds, not counting the delay). - 3
One set of events per property. An element that changes opacity, transform and width at once gets four
transitionendevents if its child also changes colour: one for each property (measured: theendlist wascolor (from the child), opacity, transform, width). Checkevent.propertyNameto know which one you are hearing, and never run your cleanup on the firsttransitionend. - 4
Events bubble. A transition on a child fires its events on the child, and they travel up to the parent's listener. In the measurement the card's listener heard the
colorevent from its child. Checkevent.target === event.currentTarget, or the property name, before you act. - 5
**When no
transitionendcomes.** Measured in Chromium 153, one case per row: with nothing to change (opacity: 1set again), no events at all. Hiding the element withdisplay: nonemid-way, settingtransition: none, or removing the element all gaverun, start, cancel. The cancel is fired on the element itself, so a listener on an ancestor will not hear a removed element's cancel. The old patternel.addEventListener('transitionend', remove)therefore leaves an element behind in at least three common cases. - 6
A safe wait. Write a helper that returns a promise and settles on whichever comes first:
transitionendfor the right property on the right target,transitioncancel, or a timeout set to duration plus delay plus a little (fromgetComputedStyle). The example below resolvesended,cancelledortimeout, so the caller can always carry on. Remove the listeners and clear the timer in every path. - 7
**
getAnimations()is another way.**el.getAnimations()lists the running transitions (each is aCSSTransitionwithtransitionProperty), and each has afinishedpromise. In a test, one that completed wasfulfilled, and one whose element was hidden wasrejectedwithAbortError.Promise.allSettled(el.getAnimations().map((a) => a.finished))waits for everything without caring about property names. Call it right after the change, because a transition that does not exist yet cannot be listed.
Explain it without notes
Why can a transitionend listener on a parent run several times for one hover?
List three situations in which a started transition never fires transitionend.
Design a helper that resolves when a transition is over, whatever happens.
Practice
Write a function closePanel(panel) that adds .closing, waits safely, and removes the panel unless it was reopened (the class closing is gone).
Use getAnimations() to wait for all of an element's transitions to finish, without caring about event names.
Done when you can
You can name the four transition events and when each fires
You filter by target and property name in a listener
You can list the cases where transitionend never comes
You can write a wait with an end, cancel and timeout path