Keyframes animation for a nav bar won't retrigger at a different position

Viewed 271

I've been trying to develop a CSS keyframes animation for a navigation bar.

You can see in the code snippet how the animation works - the red line is animated when the user clicks an element of the nav bar. The first element of the nav bar is active by default (the red line is under this element). When an element is clicked, the JS takes the properties of the animation element, as well as the properties of the element that was clicked. These properties are incorporated into new keyframes that are inserted into the single keyframes rule.

When the second element is clicked, the animation runs successfully from element 1 --> 2. The animation also runs successfully from element 1 --> 3.

But after the animation plays from element 1 --> 2, it won't play from element 2 --> 3. The animationend event does not trigger (I checked this). As of now, I'm only concerned with the animation going forwards.

After researching, I tried several methods to fix this. Removing and reattaching the animation class does not work, even with a DOM reflow being triggered. Changing the animation-play-state from 'running' to 'paused' does not work either. Other solutions, such as changing the animation-name to 'none' and then back, only generate more problems, like the position of the animation element being reset upon the ending of the animation. I truly do not know how to fix this.

I would prefer to make a flexible keyframes animation, such as this, rather than brute-forcing it. A brute force scenario would include making 6 different keyframes rules, and I want the code to be applicable to any number of elements in the navigation bar. Adding keyframes rules for every addition of an element would require exponentially more code each addition.

Thanks.

~ Code for demo ~

var keyframes = findKeyframesRule('movey');

$(document).ready(() => {

    $('div.one').click(() => {

        if (!($('div.one').hasClass('active'))) {

            /* unfinished */

        }
    
    })

    $('div.two').click(() => {

        if (!($('div.two').hasClass('active'))) {
            
            /* transfer active class */
            $('div.active').removeClass('active');
            $('div.two').addClass('active');
            
            var left = ( parseInt($('div.absolute').css('left')) / $(window).width() ) * 100;
            
            /* reset keyframes before animation */
            clearKeyframes();

            /* add new keyframes for when div.two is clicked */
            keyframes.appendRule("0% { width: 15%; left: " + left + "%;}");
            keyframes.appendRule("49.99% { width: 30%; left: " + left + "%; right: 70%;}");
            keyframes.appendRule("50% { width: 30%; left: unset; right: 70%;}");
            keyframes.appendRule("100% { width: 15%; right: 70%;}");
            
            /* first animation - add animation class */
            if (!($('div.absolute').hasClass('animateMovey'))) {
                $('div.absolute').addClass('animateMovey');

            /* animations after first - remove and reattach animation class with new keyframes */
            } else {
                $('div.absolute').removeClass('animateMovey');
                $('div.absolute').addClass('animateMovey');
            }

            /* ensure animation occurs */
            $('div.animateMovey').on('animationend', () => {
                console.log('Animation ended');
            })

        }

    })

    $('div.three').click(() => {
        
        if (!($('div.three').hasClass('active'))) {

            $('div.active').removeClass('active');
            $('div.three').addClass('active');

            var left = ( parseInt($('div.absolute').css('left')) / $(window).width() ) * 100;
            var width = 45 - left;

            clearKeyframes();

            keyframes.appendRule("0% { width: 15%; left: " + left + "%;}");
            keyframes.appendRule("49.99% { width: " + width + "%; left: " + left + "%; right: 55%;}");
            keyframes.appendRule("50% { width: " + width + "%; left: unset; right: 55%;}");
            keyframes.appendRule("100% { width: 15%; right: 55%;")
            
            if (!($('div.absolute').hasClass('animateMovey'))) {
                $('div.absolute').addClass('animateMovey');
            } else {
                $('div.absolute').removeClass('animateMovey');
                $('div.absolute').addClass('animateMovey');
            }

            $('div.animateMovey').on('animationend', () => {
                console.log('Animation ended');
            })

        }

    })

})

function findKeyframesRule(rule) {
    var ss = document.styleSheets;
    for (var i = 0; i < ss.length; ++i) {
        for (var j = 0; j < ss[i].cssRules.length; ++j) {
            if (ss[i].cssRules[j].type == window.CSSRule.KEYFRAMES_RULE && ss[i].cssRules[j].name == rule)
                return ss[i].cssRules[j];
        }
    }
    return null;
}
function clearKeyframes() {
    for (var i = 0; i <= 3; ++i) {
        if (keyframes[0]) {
            var keyToRemove = keyframes[0].keyText;
            keyframes.deleteRule(keyToRemove);
        }
    }
}
body {
    margin: 0;
}
div.nav {
    position: relative;
    display: block;
    overflow: hidden;
    width: 100%;
}
div.nav div {
    float: left;
    width: 15%;
    height: 75px;
}
div.nav div:hover {
    opacity: 0.5;
}
div.one {
    background-color: #7a7a7a;
}
div.two {
    background-color: #9e9e9e;
}
div.three {
    background-color: #bdbdbd;
}
.active {
    box-shadow: inset 3px 5px 6px #000;
}
div.animateMovey {
    animation-name: movey;
    animation-duration: 0.6s;
    animation-fill-mode: forwards;
    animation-timing-function: ease-in-out;
}
div.relative {
    position: relative;
    width: 100%;
    height: 20px;
}
div.absolute {
    position: absolute;
    background-color: #ff8c69;
    width: 15%;
    height: 100%;
}
@keyframes movey {
    100% { }
}
<div>

    <div class="nav">
        <div class="one active"></div>
        <div class="two"></div>
        <div class="three"></div>
    </div>

    <div class="relative">
        <div class="absolute"></div>
    </div>

</div>

<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>

1 Answers

Interesting question. I'm not sure why the event is not re-triggering in this case, but will suggest a few changes to your approach:

The challenging part of this effect is managing the opacity of each line. I've used animationEnd to help with that, and it appears to work fine.

Additional comments in the example code. It could be improved by handling clicks while animation is active, consolidating animation functions etc. You could also vary the animation duration depending on the number of items.

let boxes = null;
let lines = null;
let fromIndex = 0;
let toIndex = 0;

const ANIMATION_DURATION = 0.1; // seconds
const animation = {
  animating: false,
  lines: [],
  direction: "right",
  inOrOut: "in"
};

function getEls() {
  boxes = [...document.querySelectorAll(".box")];
  lines = [...document.querySelectorAll(".line")];
}

function setAnimationDuration() {
  lines.forEach((line) => {
    line.style.animationDuration = `${ANIMATION_DURATION}s`;
  });
}

function addEvents() {
  boxes.forEach((box, index) => {
    box.addEventListener("click", () => {
      // User has clicked the currently active box
      if (fromIndex === index) return;
      // Line is currently animating
      if (animation.animating) return;

      toIndex = index;

      updateActiveBox();
      handleLineAnimation();
    });
  });

  document.addEventListener("animationend", (e) => {
    // Maintain opacity on lines that animate in
    if (animation.inOrOut === "in") {
      e.target.style.opacity = 1;
    }
  });
}

function updateActiveBox() {
  boxes[fromIndex].classList.remove("active");
  boxes[toIndex].classList.add("active");
}

function updateActiveLine(line) {
  lines[fromIndex].classList.remove("active");
  line.classList.add("active");
}

function handleLineAnimation() {
  animation.animating = true;
  animation.lines = [];

  if (toIndex > fromIndex) {
    animation.direction = "right";

    for (let i = fromIndex; i <= toIndex; i++) {
      animation.lines.push(lines[i]);
    }
  } else {
    animation.direction = "left";

    for (let i = fromIndex; i >= toIndex; i--) {
      animation.lines.push(lines[i]);
    }
  }

  animate();
}

function animate() {
  const wait = (animation.lines.length - 1) * ANIMATION_DURATION * 1000;
  animation.inOrOut = "in";
  animateIn();

  setTimeout(() => {
    resetLine();
    updateActiveLine(lines[toIndex]);

    animation.inOrOut = "out";
    animateOut();

    setTimeout(() => {
      resetLine();
      onAnimationComplete();
    }, wait);
  }, wait);
}

function animateIn() {
  const {
    direction,
    lines
  } = animation;

  lines.forEach((line, index) => {
    // index = 0 is currently active, no need to animate in
    if (index > 0) {
      line.classList.add(`animate-in-${direction}`);
      line.style.animationDelay = `${(index - 1) * ANIMATION_DURATION}s`;
    }
  });
}

function animateOut() {
  const {
    direction,
    lines
  } = animation;
  lines.forEach((line, index) => {
    // lines.length - 1 is new active, don't animate out
    if (index < lines.length - 1) {
      line.classList.remove(`animate-in-${direction}`);
      line.classList.add(`animate-out-${direction}`);

      line.style.animationDelay = `${index * ANIMATION_DURATION}s`;
    }
  });
}

function resetLine() {
  const {
    direction,
    lines,
    inOrOut
  } = animation;

  lines.forEach((line) => {
    line.classList.remove(`animate-${inOrOut}-${direction}`);
    line.style.animationDelay = null;

    // After animating out, remove inline opacity
    if (inOrOut === "out") {
      if (!line.classList.contains("active")) {
        line.style.opacity = "";
      }
    }
  });
}

function onAnimationComplete() {
  animation.animating = false;
  fromIndex = toIndex;
}

function init() {
  getEls();
  setAnimationDuration();
  addEvents();
}

function reset() {
  fromIndex = 0;
  init();

  lines.forEach((line, index) => {
    line.classList.remove('active');
    line.style.opacity = "";

    boxes[index].classList.remove('active');
  });

  boxes[0].classList.add("active");
  lines[0].classList.add("active");
}

init();

// DEBUG
document.getElementById("debug").addEventListener("change", (e) => {
  document.querySelector("nav").classList.toggle("debug-on");
});

document.getElementById("add").addEventListener("click", (e) => {
  const div = document.createElement("div");
  div.classList.add("box");

  div.innerHTML = '<div class="new"></div><span class="line"></span>';
  document.querySelector("nav").appendChild(div);

  reset();
});

document.getElementById("remove").addEventListener("click", (e) => {
  const indexToRemove = boxes.length - 1;

  if (indexToRemove > 0) {
    const box = boxes[indexToRemove];
    box.parentNode.removeChild(box);

    reset();
  }
});
nav {
  display: flex;
  flex-wrap: wrap;
  overflow: hidden;
}

.debug-on .line {
  border: 1px solid;
  box-sizing: border-box;
  opacity: 0.2;
}

.box {
  display: flex;
  flex-direction: column;
  position: relative;
  float: left;
  flex: 0 0 15%;
  /* Allows the line to slide left or right with opacity: 1 */
  overflow: hidden;
}

.box>div {
  cursor: pointer;
  height: 75px;
}

.one {
  background-color: #7a7a7a;
}

.two {
  background-color: #9e9e9e;
}

.three {
  background-color: #bdbdbd;
}

.new {
  background-color: pink;
  border: 1px solid;
  box-sizing: border-box;
}

.line {
  background-color: #ff8c69;
  height: 20px;
  opacity: 0;
  pointer-events: none;
  width: 100%;
  animation-fill-mode: forwards;
  animation-timing-function: linear;
}

.active>div {
  box-shadow: inset 3px 5px 6px #000;
}

.box:hover div {
  opacity: 0.5;
}

.line.active {
  opacity: 1;
}

.line.show {
  opacity: 1;
}

.animate-in-right {
  animation-name: SLIDE_IN_RIGHT;
}

.animate-out-right {
  animation-name: SLIDE_OUT_RIGHT;
}

.animate-in-left {
  animation-name: SLIDE_IN_LEFT;
}

.animate-out-left {
  animation-name: SLIDE_OUT_LEFT;
}

@keyframes SLIDE_IN_RIGHT {
  from {
    opacity: 1;
    transform: translateX(-100%);
  }
  to {
    opacity: 1;
    transform: translateX(0);
  }
}

@keyframes SLIDE_OUT_RIGHT {
  from {
    opacity: 1;
    transform: translateX(0);
  }
  to {
    opacity: 1;
    transform: translateX(100%);
  }
}

@keyframes SLIDE_IN_LEFT {
  from {
    opacity: 1;
    transform: translateX(100%);
  }
  to {
    opacity: 1;
    transform: translateX(0);
  }
}

@keyframes SLIDE_OUT_LEFT {
  from {
    opacity: 1;
    transform: translateX(0);
  }
  to {
    opacity: 1;
    transform: translateX(-100%);
  }
}


/* for demo only */

.debug {
  background: #eee;
  padding: 1rem;
  display: inline-flex;
  flex-direction: column;
  font: 14px/1 sans-serif;
  position: fixed;
  bottom: 0;
  right: 0;
}

.debug button {
  margin-top: 1rem;
  padding: .25rem;
}
<nav>
  <div class="box active">
    <div class="one"></div>
    <span class="line active"></span>
  </div>
  <div class="box">
    <div class="two"></div>
    <span class="line"></span>
  </div>
  <div class="box">
    <div class="three"></div>
    <span class="line"></span>
  </div>
</nav>

<br><br>
<div class="debug">
  <label for="debug">Debug Lines <input type="checkbox" id="debug">
  </label>
  <button id="add">Add cell</button>
  <button id="remove">Delete cell</button>
</div>

Related