1

We have a way to detect when an animation ends using JS:

const element = $('#animatable');
element.addClass('being-animated').on("animationend", (event) => {
  console.log('Animation ended!');
});
@keyframes animateOpacity {
  0% {
    opacity: 1;
  }
  100% {
    opacity: 0;
  }
}

@keyframes animatePosition {
  0% {
    transform: translate3d(0, 0, 0);
  }
  100% {
    transform: translate3d(0, 15px, 0);
  }
}

#animatable.being-animated {
  animation: animateOpacity 1s ease 0s forwards, animatePosition 2s ease 0s forwards;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<div id="animatable">I'm probably being animated.</div>

And as you can see, JS, rightfully, because I'm hooked to the animationend event tells me "yup, an animation is done" but isn't aware of what's coming after and I'm missing the second one.

Isn't there an animation queue? Surely CSS has to register these things somewhere in the system before they're fired and I could peak inside.

TylerH
  • 20,799
  • 66
  • 75
  • 101
coolpasta
  • 725
  • 5
  • 19
  • so you want to know which one end? you want to identify the animation? – Temani Afif Sep 25 '19 at 22:47
  • @TemaniAfif I want to know if on the said element there are multiple animations queued. Knowing that an animation has ended and then waiting for N time to see if there's another one for you to proceed is bad. I want to know beforehand (or on an event, by event I mean at a given point in time) if there are multiple animations. A hook like `animationEndButReallyAllOfThem` :P – coolpasta Sep 25 '19 at 22:49
  • you have to read the value of animation and identify if there is two animation defined there then you will know how many animationEnd will get fired – Temani Afif Sep 25 '19 at 22:51
  • @TemaniAfif Yes! How...do I do that? – coolpasta Sep 25 '19 at 22:53
  • use this: https://stackoverflow.com/a/6338234/8620333 – Temani Afif Sep 25 '19 at 22:54
  • @TemaniAfif That doesn't work. – coolpasta Sep 25 '19 at 23:02
  • @TemaniAfif Ok, I made it work but I think it's unreliable due to the `animationend` event. Any tips for my answer? – coolpasta Sep 25 '19 at 23:30
  • 1
    How broad a solution do you need? Are you sure you will always have only `finite` animations? What should be the behavior of your script wrt animations that are canceled before they *end*? What about first animation ends at t+3s and second animation **starts** at t+30s? Do you want to wait during the interruption? For the exact case you shown, it's as easy as waiting only for the last one. If you wish to know if there is a currently running animation, then you can store every that caused an `animationstart` event, and remove it in its animationend, as long as the storage is not empty... – Kaiido Sep 26 '19 at 02:10
  • @Kaiido You're right. I'm currently re-working B-Dawg's function as well as mine to handle these cases. If you could give more cases so I can work on or better yet, provide yet another answer that handles these cases, I'd be grateful. – coolpasta Sep 26 '19 at 02:11
  • There isn't a clear single way of doing this, you have to define the specs of your script, we can't do it for you. Note though that it smells like you are using animations where you should have used transitions. – Kaiido Sep 26 '19 at 02:14
  • @Kaiido Check my updated answer, be as draconic as you want, I'll try to fix so everyone can benefit. Also, what difference does it make that I use transitions instead of animations? My current animation (I can upload it, it's really pretty) is not possible without `@keyframes`. **As for my current specifications: I just want, after a class was added to an element and it triggered animations (multiple), for my system to wait for it to finish so I could chain other things.** – coolpasta Sep 26 '19 at 02:17

3 Answers3

2

Disclaimer: I don't think jQuery is important to answer this question and would hurt both load and runtime performance if others choose to rely on this code after seeing this answer. So, I will be answering with vanilla JavaScript to help as many people as I can with this, but if you want to use jQuery, you can still apply the same concepts.

Answer: There isn't an animation queue, but you could make your own.

For example, you could link data about animations to your target element using a closure, and/or a Map (In the snippet below, I actually used a WeakMap in an attempt to help garbage collection). If you save animation states as true when they are completed, you could check and eventually fire a different callback when all are true, or dispatch a custom event of your own. I used the custom event approach, because it's more flexible (able to add multiple callbacks).

The following code should additionally help you avoid waiting for ALL animations in those cases where you only actually care about a couple specific ones. It should also let you handle animation events multiple times and for multiple individual elements (try running the snippet and clicking the boxes a few times)

const addAnimationEndAllEvent = (() => {
  const weakMap = new WeakMap()

  const initAnimationsObject = (element, expectedAnimations, eventName) => {
    const events = weakMap.get(element)
    const animationsCompleted = {}
    for (const animation of expectedAnimations) {
      animationsCompleted[animation] = false
    }
    events[eventName] = animationsCompleted
  }

  return (element, expectedAnimations, eventName = 'animationendall') => {
    if (!weakMap.has(element)) weakMap.set(element, {})

    if (expectedAnimations) {
      initAnimationsObject(element, expectedAnimations, eventName)
    }

    // When any animation completes...
    element.addEventListener('animationend', ({ target, animationName }) => {
      const events = weakMap.get(target)
      
      // Use all animations, if there were none provided earlier
      if (!events[eventName]) {
        initAnimationsObject(target, window.getComputedStyle(target).animationName.split(', '), eventName)
      }
      
      const animationsCompleted = events[eventName]
      
      // Ensure this animation should be tracked
      if (!(animationName in animationsCompleted)) return

      // Mark the current animation as complete (true)
      animationsCompleted[animationName] = true

      // If every animation is now completed...
      if (Object.values(animationsCompleted).every(
        isCompleted => isCompleted === true
      )) {
        const animations = Object.keys(animationsCompleted)

        // Fire the event
        target.dispatchEvent(new CustomEvent(eventName, {
          detail: { target, animations },
        }))

        // Reset for next time - set all animations to not complete (false)
        initAnimationsObject(target, animations, eventName)
      }
    })
  }
})()

const toggleAnimation = ({ target }) => {
  target.classList.toggle('being-animated')
}

document.querySelectorAll('.animatable').forEach(element => {
  // Wait for all animations before firing the default event "animationendall"
  addAnimationEndAllEvent(element)

  // Wait for the provided animations before firing the event "animationend2"
  addAnimationEndAllEvent(element, [
    'animateOpacity',
    'animatePosition'
  ], 'animationend2')

  // Listen for our added "animationendall" event
  element.addEventListener('animationendall', ({detail: { target, animations }}) => {
    console.log(`Animations: ${animations.join(', ')} - Complete`)
  })

  // Listen for our added "animationend2" event
  element.addEventListener('animationend2', ({detail: { target, animations }}) => {
    console.log(`Animations: ${animations.join(', ')} - Complete`)
  })

  // Just updated this to function on click, so we can test animation multiple times
  element.addEventListener('click', toggleAnimation)
})
.animatable {
  margin: 5px;
  width: 100px;
  height: 100px;
  background: black;
}

@keyframes animateOpacity {
  0% {
    opacity: 1;
  }
  100% {
    opacity: 0;
  }
}

@keyframes animatePosition {
  0% {
    transform: translate3d(0, 0, 0);
  }
  100% {
    transform: translate3d(0, 15px, 0);
  }
}

@keyframes animateRotation {
  100% {
    transform: rotate(360deg);
  }
}

.animatable.being-animated {
  animation:
    animateOpacity 1s ease 0s forwards,
    animatePosition 1.5s ease 0s forwards,
    animateRotation 2s ease 0s forwards;
}
<div class="animatable"></div>
<div class="animatable"></div>
BCDeWitt
  • 4,540
  • 2
  • 21
  • 34
  • 1
    This is incredible. Consider wrapping this inside a nice function and release it. There is a problem, however. The dependability on knowing the animations beforehand, `animateOpacity, animatePosition` is a very big pain point. – coolpasta Sep 26 '19 at 00:58
  • Additionally, I don't understand exactly what the line `animations.forEach(animation => animationsCompleted[animation] = false)` is for. Why do I need a "reset" if I'm done with the animations? – coolpasta Sep 26 '19 at 01:41
  • 1
    I tried to demonstrate a situation where you aren't actually done listening for these events in the snippet (click the boxes a few times). Removing and re-adding the `being-animated` class would not work correctly after the first run if you didn't reset. Your Promise-based re-write of this will have an issue being run more than once, too. – BCDeWitt Sep 26 '19 at 11:09
  • 1
    @coolpasta Updated the answer to use events and made the animation list an optional feature – BCDeWitt Sep 26 '19 at 14:33
  • Thank you. This is very, very nice stuff. Again, consider putting this into a mini-library. – coolpasta Sep 26 '19 at 15:33
  • Glad it helps! I'll certainly consider doing that – BCDeWitt Sep 26 '19 at 15:36
1

@BDawg's awesome snippet is more flexible and thorough, it certainly deserves to be the accepted answer. That said, I was inspired to see if a less verbose approach was feasible. Here's what I came up with.

It's pretty self-explanitory, but basically the concept is that all the animation properties' indexes correlate, and we can use that to find the name of the animation that finishes last.

const getFinalAnimationName = el => {
  const style = window.getComputedStyle(el)
  
  // get the combined duration of all timing properties
  const [durations, iterations, delays] = ['Duration', 'IterationCount', 'Delay']
    .map(prop => style[`animation${prop}`].split(', ')
      .map(val => Number(val.replace(/[^0-9\.]/g, ''))))
  const combinedDurations = durations.map((duration, idx) =>
    duration * iterations[idx] + delays[idx])
  
  // use the index of the longest duration to select the animation name
  const finalAnimationIdx = combinedDurations
    .findIndex(d => d === Math.max(...combinedDurations))
  return style.animationName.split(', ')[finalAnimationIdx]
}

// pipe your element through this function to give it the ability to dispatch the 'animationendall' event
const addAnimationEndAllEvent = el => {
  const animationendall = new CustomEvent('animationendall')
  el.addEventListener('animationend', ({animationName}) =>
    animationName === getFinalAnimationName(el) &&
      el.dispatchEvent(animationendall))
  return el
}

// example usage
const animatable = document.querySelector('.animatable')
addAnimationEndAllEvent(animatable)
  .addEventListener('animationendall', () => console.log('All animations have finished'))
.animatable {
  width: 50px;
  height: 50px;
  background-color: red;
  position: relative;
  left: 0;
  animation: 1.5s slidein, 1s fadein;
}

@keyframes slidein {
  0% { left: 100vw; }
  100% { left: 0; }
}

@keyframes fadein {
  0% { opacity: 0; }
  100% { opacity: 1; }
}
<div class="animatable"></div>
  • This is more or less my initial approach by looking at the computed style. This suffers from a problem, tho (not sure about your answer). What if the same class that triggered the animations gets added right after it was removed? – coolpasta Sep 26 '19 at 18:21
  • This custom event will fire any time an animation happens. So, if the class holding the animation property gets removed and then added again, the animation will happen a second time because of the CSS. That will again trigger this event. Is that the desired result? –  Sep 26 '19 at 18:37
0

First technique:

Add a class to an element, then handle every animation and wait for them to end, no matter what. This is the common way to do things where you trigger animations by classes.

As per Kaiido's comment and pointing out, this waits for every single animation, no matter how long to finish. This was the motivation behind all of this: create a nice animation and make JS aware of it (no matter how complex / long) finishing it so you could then chain other things.

If you don't do this, you might have a nice animation running and suddenly being cut by something else and...that's bad.

const triggerAnimationWithClass = (classToAdd, toWhat) => {
    const element = document.querySelector(toWhat);
    /**
     * Initialize the count with 1, because you'll always have at least one animation no matter what.
     */
    let animationCount = 1;
    return new Promise((resolve, reject) => {
        element.addEventListener('animationend', (event) => {
            if((window.getComputedStyle(element).animationName).split(',').length - animationCount === 0) {
                /**
                 * Remove the current function being hooked once we're done. When a class gets added that contains any N number of animations,
                 * we're running in a synchronous environment. There is virtually no way for another animation to happen at this point, so, we're
                 * surgically looking at animations that only happen when our classToAdd gets applied then hooking off to not create conflicts.
                 */
                element.removeEventListener('animationend', this);

                const animationsDonePackage = {
                    'animatedWithClass': classToAdd,
                    'animatedElement': toWhat,
                    'animatedDoneTime': new Date().getTime()
                };

                resolve(animationsDonePackage);
            } else {
                animationCount++;
            }
        });
        element.classList.add(classToAdd);
    });
}

This handles multiple classes being added. Let's assume that from the outside, someone adds yet another class at the same time (weird, but, let's say it happens) you've added yours. All the animations on that element are then treated as one and the function will wait for all of them to finish.

Second technique:

Based on @B-Dawg's answer. Handle a set of animations, based on name (CSS animation names), not class, please read the after-word:

const onAnimationsComplete = ({element, animationsToLookFor}) => {
    const animationsMap = new WeakMap();

    if(!animationsMap.has(element)) {
        const animationsCompleted = {};

        for(const animation of animationsToLookFor) {
            animationsCompleted[animation] = false;
        }

        animationsMap.set(element, animationsCompleted);
    }

    return new Promise((resolve, reject) => {
        // When any animation completes...
        element.addEventListener('animationend', ({target, animationName, elapsedTime}) => {
            const animationsCompleted = animationsMap.get(target);

            animationsCompleted[animationName] = true;

            // If every animation is now completed...
            if(Object.values(animationsCompleted).every(isCompleted => isCompleted === true)) {
                const animations = Object.keys(animationsCompleted);

                // Reset for next time - set all animations to not complete (false)
                animations.forEach(animation => animationsCompleted[animation] = false);

                //Remove the listener once we're done.
                element.removeEventListener('animationend', this);

                resolve({
                    'animationsDone': animationsToLookFor
                });
            }
        });
    });
};

This has a bug. Assuming that a new animation comes from, say, maybe a new class, if it's not put in the animationsToLookFor list, this never resolves.

Trying to fix it but if we're talking about a precise list of animations you're looking for, this is the go-to.

Community
  • 1
  • 1
coolpasta
  • 725
  • 5
  • 19