Command Palette

Search for a command to run...

Hectal
Shots in this scene
SHOT 3.5Beginner ~20 min · shot 5 of 6

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.

0/6 · 0%

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. 1

    Give the card a delay

    In "One transition, four properties" change the card's transition to opacity 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. transitionrun for opacity has already fired. transitionstart has not.

  2. 2

    Cancel on purpose

    In "When transitionend never comes" change the display: none interruption to el.style.opacity = "0.3" (the value it was already heading to) and see the transition finish as ended, not cancelled: changing to the same target does not cancel it.

Code & diagrams

One transition, four properties

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.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

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, width
When transitionend never comes

Five 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.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

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: cancelled

Break 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.

terminal
$ Add the closing class and check the DOM after a second
── what you'll see ──
The panel is still in the DOM. With `display: none` there is no transition, so neither `transitionend` nor `transitioncancel` fired.
In this page's last example the `nothing changes` row shows the shape of this bug: no events, helper timeout.

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.

  • ▸

    transitioncancel also 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-motion you may set durations to 0s, and then no events fire at all. Code that depends on transitionend to finish must have the fallback (or check matchMedia first), or reduced-motion users get a stuck interface. Phase 7 builds this in.

Remember this

  1. 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 transitionend and 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. 2

    The four events. transitionrun fires when the transition is created, before any delay. transitionstart fires when the delay is over and the change begins. transitionend fires when the transition completes. transitioncancel fires when it is stopped before completing. In a measurement with transition: opacity 200ms linear 300ms, run came in the first frame, start about 300 ms later, and end about 200 ms after that, with elapsedTime 0.2 (seconds, not counting the delay).

  3. 3

    One set of events per property. An element that changes opacity, transform and width at once gets four transitionend events if its child also changes colour: one for each property (measured: the end list was color (from the child), opacity, transform, width). Check event.propertyName to know which one you are hearing, and never run your cleanup on the first transitionend.

  4. 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 color event from its child. Check event.target === event.currentTarget, or the property name, before you act.

  5. 5

    **When no transitionend comes.** Measured in Chromium 153, one case per row: with nothing to change (opacity: 1 set again), no events at all. Hiding the element with display: none mid-way, setting transition: none, or removing the element all gave run, 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 pattern el.addEventListener('transitionend', remove) therefore leaves an element behind in at least three common cases.

  6. 6

    A safe wait. Write a helper that returns a promise and settles on whichever comes first: transitionend for the right property on the right target, transitioncancel, or a timeout set to duration plus delay plus a little (from getComputedStyle). The example below resolves ended, cancelled or timeout, so the caller can always carry on. Remove the listeners and clear the timer in every path.

  7. 7

    **getAnimations() is another way.** el.getAnimations() lists the running transitions (each is a CSSTransition with transitionProperty), and each has a finished promise. In a test, one that completed was fulfilled, and one whose element was hidden was rejected with AbortError. 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

01

Why can a transitionend listener on a parent run several times for one hover?

02

List three situations in which a started transition never fires transitionend.

03

Design a helper that resolves when a transition is over, whatever happens.

Practice

01

Write a function closePanel(panel) that adds .closing, waits safely, and removes the panel unless it was reopened (the class closing is gone).

02

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