Search code examples
javascripthtmlcsscss-animations

How can I remove a class without triggering a CSS animation?


I'm trying to create a website in which there are some simple CSS animations:

  1. fade-in: Causes the element to fade into view, and
  2. error: Causes the element to turn red and shake for a brief moment.

When I trigger the error animation through JS, once it's done, the fade-in animation plays as well, which is not desired. Here's the CSS and JS:

style.css:

...

/* All children of the `splash` class are subject to the `fade-in` animation */

.splash * {
    animation: fade-in 1s ease-in;
    opacity: 1;
}


@keyframes fade-in {
    0% {
        opacity: 0;
    }
    100% {
        opacity: 1;
    }
}

.error {
    animation: shake 0.4s ease-in-out;
    border-color: red;
}

@keyframes shake {
    0% { transform: translateX(0); }
    25% { transform: translateX(-5px); }
    50% { transform: translateX(5px); }
    75% { transform: translateX(-5px); }
    100% { transform: translateX(0px); }
}

.no_transition {
    animation: none;
    transition: none;
}

...

script.js:

...

function err(elem, msg) {
    if (msg) alert(msg);
    // start the transition
    elem.classList.add('error');
    elem.disabled = true;
    setTimeout(() => {
        // 1 second later, remove the `error` class.
        elem.classList.add('no_transition');
        elem.classList.remove('error');
        elem.disabled = false;
        elem.classList.remove('no_transition'); // this is what triggers the `fade-in` animation again
    }, 1000);
}

...

Since adding / removing a class will trigger the CSS animation, I tried creating a separate class (no-transition) that blocks all animations and transitions. However, once I remove it, fade-in triggers once again.

Is there a way to work around this, and add / remove these CSS classes without triggering the fade-in animation?


Solution

  • You could set your animation so that it contains the two sets of animations, this way the first one wouldn't restart:

    document.querySelector(".splash").addEventListener("click", ({
      target: elem,
      currentTarget
    }) => {
      if (elem === currentTarget) {
        return;
      }
      elem.classList.add('error');
      setTimeout(() => {
        // 1 second later, remove the `error` class.
        elem.classList.remove('error');
      }, 1000);
    })
    console.log("click on either element to trigger the .error class");
    .splash * {
      border: 1px solid;
      animation: fade-in 1s ease-in;
      opacity: 1;
    }
    
    @keyframes fade-in {
      0% {
        opacity: 0;
      }
      100% {
        opacity: 1;
      }
    }
    
    .error {
      /* Also set the fade-in class */
      animation: fade-in 1s ease-in, shake 0.4s ease-in-out;
      border-color: red;
    }
    
    @keyframes shake {
      0% {
        transform: translateX(0);
      }
      25% {
        transform: translateX(-5px);
      }
      50% {
        transform: translateX(5px);
      }
      75% {
        transform: translateX(-5px);
      }
      100% {
        transform: translateX(0px);
      }
    }
    <div class="splash">
      <div>
        foo
      </div>
      <div>
        bar
      </div>
    </div>

    But that implies the fade-in animation is always active when the shake one is.

    So it actually sounds like you don't want fade-in to be an animation, but a transition instead. If you have trouble making it kick when appending your elements, then see this Q/A. Basically, you have to wait for (or force) a reflow before you set the target of the animation.

    // Wait for next browser reflow
    const observer = new ResizeObserver(() => {
      observer.disconnect();
      document.querySelector(".splash").classList.add("in");
    });
    observer.observe(document.body);
    
    document.querySelector(".splash").addEventListener("click", ({
      target: elem,
      currentTarget
    }) => {
      if (elem === currentTarget) {
        return;
      }
      elem.classList.add('error');
      setTimeout(() => {
        // 1 second later, remove the `error` class.
        elem.classList.remove('error');
      }, 1000);
    })
    console.log("click on either element to trigger the .error class");
    .splash * {
      border: 1px solid;
      transition: opacity 1s ease-in;
      opacity: 0;
    }
    
    .splash.in * {
      opacity: 1;
    }
    
    .error {
      animation: shake 0.4s ease-in-out;
      border-color: red;
    }
    
    @keyframes shake {
      0% {
        transform: translateX(0);
      }
      25% {
        transform: translateX(-5px);
      }
      50% {
        transform: translateX(5px);
      }
      75% {
        transform: translateX(-5px);
      }
      100% {
        transform: translateX(0px);
      }
    }
    <div class="splash">
      <div>
        foo
      </div>
      <div>
        bar
      </div>
    </div>

    But, since you're gonna use JS to control these transitions and animations, then it's even better to use the Web Animation API to control these directly rather than do a complex round-trip through DOM then CSSOM.

    // fade-in all children at page load
    document.querySelectorAll(".splash *").forEach((elem) => {
      elem.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 1000 });
    });
    // shake on click
    document.querySelector(".splash").addEventListener("click", ({target: elem, currentTarget}) => {
      if (elem === currentTarget) { return; }
      elem.animate([
        { transform: "translateX(0)" },
        { transform: "translateX(-5px)" },
        { transform: "translateX(5px)" },
        { transform: "translateX(-5px)" },
        { transform: "translateX(0px)" },
      ], { duration: 400 });
      // We can even use it for non animated temporary styles like the 1s border-color
      elem.animate([{ borderColor: "red" }, { borderColor: "red" }], { duration: 1000 })
      // .finished.then(() => { the animation is over });
      // If you want to do something after the animation is over
    })
    console.log("click on either element to trigger the .error class");
    .splash * {
      border: 1px solid;
    }
    <div class="splash">
      <div>
        foo
      </div>
      <div>
        bar
      </div>
    </div>