How to make multiple animations trigger in sequence on the same element using JavaScript?

66 Views Asked by At

I'm new to JavaScript, and working on a startup application. I'm trying to write code that will cause an animation to execute multiple times (in sequence) on the same text element, with the text changing between animations. The text change and second animation should not occur until the first animation is complete, and so on.

With the code that I've written, the text changes a few times over the course of a single animation, then hangs indefinitely. I'm assuming it's an issue with the async/await or the animationend listener... does anyone with more experience (basically everyone, haha) have a direction to point me in? I'd appreciate any input!

Here's example HTML with my JS code:

UPDATE: Added a executable Stack Snippet.

async function initAnim() {
    await setDialogue("First text");
    await setDialogue("Second text");
    await setDialogue("Third text");
    await setDialogue("Fourth text");
    await setDialogue("Fifth text");
}

async function setDialogue(text) {
    const dialogueEl = document.querySelector(".dialogue");
    dialogueEl.textContent = text;
    dialogueEl.classList.add("dialogueEnter");
    await waitforAnimation(dialogueEl);
    dialogueEl.classList.remove("dialogueEnter");
}

function waitforAnimation(element) {
    return new Promise(resolve => {
        function handleAnimationEnd() {
            element.removeEventListener('animationend', handleAnimationEnd);
            resolve();
        }

        element.addEventListener('animationend', handleAnimationEnd);
    });
}
.dialogueEnter {
    animation: tilt-n-move-shaking 0.25s forwards,
        text-pulse 0.6s forwards;
}

@keyframes tilt-n-move-shaking {
    0% { transform: translate(0, 0) rotate(0deg); }
    25% { transform: translate(10px, 10px) rotate(10deg); }
    50% { transform: translate(0, 0) rotate(0eg); }
    75% { transform: translate(-10px, 10px) rotate(-10deg); }
    100% { transform: translate(0, 0) rotate(0deg); }
  }

@keyframes text-pulse {
    0% { font-size: 1em }
    75% { font-size: 2em }
    100% { font-size: 1.6em }
}
<div>
  <h1 class="dialogue" onclick="initAnim()">Click me</h1>
</div>

1

There are 1 best solutions below

0
Kaiido On

There are two issues here.
The first one, which is the biggest, is that between two calls to setDialogue, you do remove and add the class synchronously.

Indeed, if we unwrap the code that would give,

dialogueEl.textContent = "First text";
await waitforAnimation(dialogueEl);
// Sync after this line
dialogueEl.classList.remove("dialogueEnter"); // <- end first call to setDialogue()
dialogueEl.textContent = "Second text";       // <- begin second call to setDialogue()
dialogueEl.classList.add("dialogueEnter");

For the CSSOM, the classList never changed, because it will only recalculate during the next reflow and thus it won't see that a new animation should have started. You can refer to this answer of mine for more details on this behavior. A quick fix is to force a synchronous reflow ourselves by using one of the triggers.

The second issue is that you are setting two animations per element with a different duration. The animationend event you're catching is the one of the shorter tilt-n-move-shaking. To wait for the longer one only, you can check the animationName property of the animation event you receive:

async function initAnim() {
  await setDialogue("First text");
  await setDialogue("Second text");
  await setDialogue("Third text");
  await setDialogue("Fourth text");
  await setDialogue("Fifth text");
}

async function setDialogue(text) {
  const dialogueEl = document.querySelector(".dialogue");
  dialogueEl.textContent = text;
  dialogueEl.offsetWidth; // force a reflow
  dialogueEl.classList.add("dialogueEnter");
  await waitforAnimation(dialogueEl);
  dialogueEl.classList.remove("dialogueEnter");
}

function waitforAnimation(element) {
  return new Promise(resolve => {
    function handleAnimationEnd(evt) {
      if (evt.animationName === "text-pulse") {
        element.removeEventListener('animationend', handleAnimationEnd);
        resolve();
      } else {
        console.log("ignoring ", evt.animationName);
      }
    }
    element.addEventListener('animationend', handleAnimationEnd);
  });
}
.dialogueEnter {
    animation: tilt-n-move-shaking 0.25s forwards,
        text-pulse 0.6s forwards;
}

@keyframes tilt-n-move-shaking {
    0% { transform: translate(0, 0) rotate(0deg); }
    25% { transform: translate(10px, 10px) rotate(10deg); }
    50% { transform: translate(0, 0) rotate(0deg); } /* <- There was a typo here */
    75% { transform: translate(-10px, 10px) rotate(-10deg); }
    100% { transform: translate(0, 0) rotate(0deg); }
  }

@keyframes text-pulse {
    0% { font-size: 1em }
    75% { font-size: 2em }
    100% { font-size: 1.6em }
}
<div>
  <h1 class="dialogue" onclick="initAnim()">Click me</h1>
</div>

However, all this could be largely simplified with the Web Animations API.
There you can tap directly into the animation object that the CSSOM will produce, without going through the long and slow path of DOM -> CSSOM -> renderer. You don't need to worry about when the next reflow will happen, you don't need to invalidate all the boxes of all the elements in the page, and you have all in a single place, making the maintenance easier.

const shakingKeyframes = [
 { transform: "translate(0, 0) rotate(0deg)" },
 { transform: "translate(10px, 10px) rotate(10deg)" },
 { transform: "translate(0, 0) rotate(0deg)" },
 { transform: "translate(-10px, 10px) rotate(-10deg)" },
 { transform: "translate(0, 0) rotate(0deg)" },
];
const shakingOptions = { duration: 250, fill: "forwards" };
const pulseKeyframes = [
  { fontSize: "1em" },
  { fontSize: "2em" },
  { fontSize: "1.6em" },
];
const pulseOptions = { duration: 600, fill: "forwards" };

async function initAnim() {
  await setDialogue("First text");
  await setDialogue("Second text");
  await setDialogue("Third text");
  await setDialogue("Fourth text");
  await setDialogue("Fifth text");
}

async function setDialogue(text) {
  const dialogueEl = document.querySelector(".dialogue");
  dialogueEl.textContent = text;
  const shakingAnim = dialogueEl.animate(shakingKeyframes, shakingOptions);
  const pulseAnim = dialogueEl.animate(pulseKeyframes, pulseOptions);
  await Promise.all([shakingAnim.finished, pulseAnim.finished]);
}
<div>
  <h1 class="dialogue" onclick="initAnim()">Click me</h1>
</div>