photo of an open road with mountains
Photo by Luke Stackpoole on Unsplash

How to Listen for CSS Events in Javascript

A guide to working with CSS animation and transition events.

6 min read

When building complex UIs, sometimes you might want to run a Javascript function based on the current state of an interaction in CSS. For example, you may want to know when an animation starts before beginning a timer. Or when a transition ends, you could send some analytic data. Fortunately, there are several events we can hook into for both animations and transitions that make scenarios like these easy.

In this article, you'll learn how to detect CSS animation events in Javascript. The second part will focus on detecting CSS transition events.

A note about browser compatibility: Most of these events are supported across browsers, but some are more experimental or inconsistent. Please refer to the MDN links or caniuse.com for more complete information.

CSS Animation Events

The HTMLElement interface has four different CSS animation events to be used in combination with addEventListener.

All of these events can be accessed via property name as well, beginning with on (e.g. onanimationstart or onanimationend).

Animationstart

Let's begin with animationstart. This event works just as its name implies and runs as soon as an animation starts. It will take into account any animation-delay value that's set and waits until that has elapsed before running.


// element is an HTML element with a CSS animation property
const element = document.querySelector(".an-element");

element.addEventListener("animationstart", () => {
  console.log('animation has started');
});

Animationend

Next we have the animationend event that runs when an animation ends. It does not run if the animation property is removed from the element or if the element is removed from the DOM. If that's the case, the animationcancel event would run instead.


element.addEventListener("animationend", () => {
  console.log('animation has ended');
});

Animationcancel

The animationcancel event runs if an animation is canceled or aborted. As mentioned above, this could happen if the element is removed from the DOM or if the animation property is removed.


element.addEventListener("animationcancel", () => {
  console.log('animation was cancelled');
});

Animationiteration

The animationiteration event runs when an iteration of an animation ends and another begins, via the animation-iteration-count CSS property. For example, if you have an animation that repeats three times, the event would run twice: once for each ending and beginning.


element.addEventListener("animationiteration", () => {
  console.log('animation has iterated once');
});

Now let's use these in an example. We'll build an interaction that updates a counter and some text using event listeners with CSS animation.


Start with the following HTML. This defines some buttons and markup to use.


<div class="buttons">
  <button class="play">Play</button>
  <button class="cancel">Cancel</button>
</div>
<div class="container">
  <div class="circle"></div>
  <p class="message"></p>
</div>

Now for some CSS.


.container {
  max-width: 500px;
  margin: auto;
  height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}

.message {
  color: #333;
  text-align: center;
  margin-top: 40px;
}

.circle {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 150px;
  width: 150px;
  background: linear-gradient(
    90deg,
    hsla(148, 89%, 78%, 1) 0%,
    hsla(210, 81%, 22%, 1) 100%
  );
  color: #fff;
  font-size: 60px;
  border-radius: 90px;
  opacity: 0;
  transition: opacity 1s ease;
}

.circle.play {
  animation: expandCollapse 7s ease;
  animation-iteration-count: 3;
}

@keyframes expandCollapse {
  0% {
    transform: scale(0.3);
  }

  50% {
    opacity: 1;
    transform: scale(1.4);
  }

  100% {
    transform: scale(0.3);
  }
}

We won't go through all of the styles above, but the key thing to note is that we have a .play class that contains the animation property, which is then added to the circle div when the play button is clicked.

Next we query for some elements, including the .circle. We'll also initialize a counter to increment.


const playButton = document.querySelector(".play");
const cancelButton = document.querySelector(".cancel");
const message = document.querySelector(".message");
const circle = document.querySelector(".circle");
let counter;

Now we need a click event for the play button. It will start the animation by adding the play class and setting the counter value to 3.


playButton.addEventListener("click", () => {
  circle.classList.add("play");
  counter = 3;
});

Let's finally add our animation event listeners.


circle.addEventListener("animationstart", () => {
  circle.innerText = counter;
  message.innerText = "Breathe";
});

When the animation starts, we want to update the circle to display the counter value and set some message text.


circle.addEventListener("animationiteration", () => {
  --counter;
  circle.innerText = counter;
  message.innerText = "Keep breathing";
});

When iterating, decrement the counter value and update the message.


circle.addEventListener("animationend", () => {
  circle.innerText = "";
  circle.classList.remove("play");
  message.innerText = "Have a great day!";
});

When the animation ends, remove both the play class and the counter value, and then display some final message text.

One last thing to demonstrate is canceling the animation. To do that, we can add a click event to the cancelButton to remove the circle element from the DOM.


cancelButton.addEventListener("click", () => {
  circle.remove();
});

If this occurs while the animation is running, the animationcancel event will fire. We can then update our messaging to show an error message.


circle.addEventListener("animationcancel", () => {
  message.innerText = "There was an error 😟";
});

CSS Transition Events

Now that we've covered animation events, we can move on to CSS transition events. The overall idea is similar.

Transitionstart

The transitionstart event runs as soon as the actual transition starts, and after any transition-delay value has elapsed.


const element = document.querySelector(".an-element");

element.addEventListener("transitionstart", () => {
  console.log('transition has started');
});

Transitionend

The transitionend event will run when a transition ends in both directions: on completion and also when reverting back to the initial state. For example, imagine hovering over a button that has a one-second transition of the background color. transitionend would run both when the transition ends while hovering on, and at the end of the transition while hovering off.


element.addEventListener("transitionend", () => {
  console.log('transition has ended');
});

There are a few important cases where this event will not run:

  1. The element being transitioned is removed from the DOM.
  2. The transition property is removed.
  3. The element is set to display: none at some point during the transition.

Transitionrun

The transitionrun event runs when a transition is created, but before any transition-delay begins. It's different from transitionstart in that it will begin before transitionstart if a delay is applied, and at the same time if there is no delay. Think of it as a way of marking the actual start of a transition.


element.addEventListener("transitionrun", () => {
  console.log('transition is running');
});

Transitioncancel

Finally there's transitioncancel, which runs if a transition is cancelled between transitionrun and before transitionend.


element.addEventListener("transitioncancel", () => {
  console.log('transition was cancelled');
});

Imagine the same button hover example. transitioncancel will run if you hover over a button with a one-second transition, then hover off before the transition finishes.

It's important to note that if a transition is cancelled, the transitionend event won't run.

I noticed some inconsistency between different browsers with this event. In Chrome and Safari, I could only get transitioncancel to trigger between transitionrun and transitionstart. In Firefox, it could be cancelled right up until transitionend runs. You can see this yourself in the demo below across various browsers.

Let's finish what we learned with a quick example. We'll add a CSS transition hover effect to a button to visualize these events.

Start with some basic HTML.


<div class="container">
  <button class="btn">Hover</button>
  <span class="status">Idle</span>
</div>

Next for some styling.


.container {
  max-width: 500px;
  margin: auto;
  height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}

.btn {
  background: transparent;
  border: 2px solid #2a9d8f;
  cursor: pointer;
  padding: 16px 36px;
  font-size: 24px;
  position: relative;
  overflow: hidden;
  color: #2a9d8f;
}

.btn::after {
  content: "";
  display: block;
  background: #9bded6;
  position: absolute;
  width: 100%;
  height: 100%;
  top: 0;
  left: 0;
  right: 0;
  transform: translateY(100%);
  transition: transform 2s ease;
  transition-delay: 1s;
  z-index: -1;
}

.btn:hover::after {
  transform: translateY(0);
}

.status {
  display: block;
  margin-top: 20px;
}

The important parts here are the transition and the transition-delay. We learned above that the presence of a delay determines when the transitionstart event actually starts. For our demo, we're adding it to get a more noticeable difference between events.

Now that we have a button, let's start listening for events.

First, query for the button and the status span.


const button = document.querySelector(".btn");
const status = document.querySelector(".status");

Next, add the event listeners for transitionrun, transitionstart, transitionend and transitioncancel. When each of them run, update the innerText value of the status span.


button.addEventListener("transitionrun", () => {
  status.innerText = "Running";
});

button.addEventListener("transitionstart", () => {
  status.innerText = "Started";
});

button.addEventListener("transitionend", () => {
  status.innerText = "Ended";
});

button.addEventListener("transitioncancel", () => {
  status.innerText = "Cancelled";
});

Now if you experiment by hovering over the button, you should see all four events working!

Conclusion

While writing this article, I was reminded just how useful these animation and transition events can be. What we saw in the examples above could have probably been done by setting a series of setTimeout functions that line up with the animation timing, but reacting to the events themselves can keep our code much more straightforward and synchronized.