cssanimationcss-animationscss-transitionsbezier

Match up ease-in-out with ease-out


I have two elements with animation. The first elements animation lasts 1 second and has an easing curve of cubic-bezier(0.5, 0, 0.5, 1) (easing in and out). The second elements animation starts 0.5s after the first one, lasts for 0.5s and has an easing curve of cubic-bezier(0, 0, 0.5, 1) (only easing out). I thought this would make their animation match up since both of the curves end with the same easing, but the first element moves a bit faster mid motion than the second one. How do I make the animation match up? And for that matter, how do I make any ease-in-out curve match up with an ease-in/ease-out curve in a similar situation?

Example of problem:

const first = document.getElementById("first");
const second = document.getElementById("second");

function animateit() {
  first.classList.add("animated");
  setTimeout(() => second.classList.add('animated'), 500)
}

function reset() {
  first.classList.remove('animated');
  second.classList.remove('animated');
}
* {
  margin: 5px;
}

div {
  width: 50px;
  height: 50px;
  background: black;
}

#first {
  transition: 1s cubic-bezier(0.5, 0, 0.5, 1);
}

#second {
  transition: 0.5s cubic-bezier(0, 0, 0.5, 1);
  transform: translateX(100px);
}

.animated {
  transform: translateX(200px) !important;
}
<button onclick='animateit()'>animate</button>
<button onclick='reset()'>reset</button>
<div id='first'></div>
<div id='second'></div>

I've tried changing the easing of the second element and I've gotten very close with cubic-bezier(0.3, 0.6, 0.5, 1) but it still doesn't match up completely.


Solution

  • Bezier curves are non-linear, so if you take two curves, with one curve starting at A and ending at B, and another curve starting that (A+B)/2 and ending at B, both with ease-in-out rules, then both curves will have their maximum speed at the midpoint, but the curves don't look the same and will not line up, speed wise:

    0.5, 0, 0.5, 1 looks like this:

    A ease-in-out bezier curve

    So, if we want the curve that only captures "the second half of that curve", i.e. this section:

    A ease-in-out bezier curve with the second half being highlighted

    then we need to split the cubic curve at that midpoint. Doing so gives us two curves, the first of which we don't care about, and the second of which starts at (0.5,0.5), has its first control point at (0.625, 0.75), its second control point at (0.75, 1), and its end point at (1,1):

    A ease-in-out bezier curve and copies of it being split into its first and second half

    Now, that's the wrong start for CSS purposes, but Bezier curves are invariant to scaling (as long as we scale x and y by the same amount) so we can just scale all the coordinates so that we get a curve that starts at (0,0), with the first control point at (0.25, 0.5), the second control point at (0.5, 1), and then ending at (1,1), exactly as CSS wants:

    #second {
      transition: 0.5s cubic-bezier(0.25, 0.5, 0.5, 1); /* heck yeah! */
      transform: translateX(100px);
    }
    

    We've done the math, go us! All we need to do is update the cubic values and we're done!

    Except... not really? If we just use those values (and you ran into this yourself already) then we see that things only seem to work "sometimes", but just as often as not the timings are off and things don't line up at all:

    const first = document.getElementById("first");
    const second = document.getElementById("second");
    
    function animateit() {
      first.classList.add("animated");
      setTimeout(() => second.classList.add('animated'), 500)
    }
    
    function reset() {
      first.classList.remove('animated');
      second.classList.remove('animated');
    }
    * {
      margin: 5px;
    }
    
    div {
      width: 50px;
      height: 50px;
      background: black;
    }
    
    #first {
      transition: 1s cubic-bezier(0.5, 0, 0.5, 1);
    }
    
    #second {
      transition: 0.5s cubic-bezier(0.25, 0.5, 0.5, 1);
      transform: translateX(100px);
    }
    
    .animated {
      transform: translateX(200px) !important;
    }
    <button onclick='animateit()'>animate</button>
    <button onclick='reset()'>reset</button>
    <div id='first'></div>
    <div id='second'></div>

    What's going on?

    As it turns out: JavaScript is going on, and we don't want it to: setTimeout (and setInterval) are extremely unreliable in terms of when they'll fire, because the only guarantee that you get is that they'll fire "at least X milliseconds from now"... but nothing else. If a setTimeout for 500ms takes 501ms, that's perfectly within spec. If it takes 510ms, that's also within spec. And if it takes an hour to fire, that's still within spec. We cannot use JS timers if we need events to fire at a specific time. JS simply doesn't have anything for you to do that.

    (at least not without some super creative hacks)

    Thankfully CSS offers us a super handy tool that lets us sidestep the problem completely: instead of running timers, we can just use CSS animations and take advantage of the animation delay rule. We can simply set up our CSS so that we have the same animation for both boxes, from "their start position" (which we can use a CSS variable for that's 0px for the first box and half the distance to the end for the second box) to "the end position" (which is 200px for both but I'm going to make that 400px so the effect is even more obvious):

    #group {
      --base: 0px;
      --end: 400px;
    
      .box {
        transform: translateX(var(--base));
      }
    
      .second {
        --base: calc(var(--end) / 2 );
      }
    }
    

    And then we tell the first box to animate for 1 second, starting immediately, and we tell the second box to animate for half a second, after waiting half a second:

    .first {
      animation-name: move;
      animation-duration: 1s;
      animation-timing-function: cubic-bezier(.5, 0, .5, 1);
    }
    
    .second {
      animation-name: move;
      animation-delay: 0.5s;
      animation-duration: 0.5s;
      animation-timing-function: cubic-bezier(.25, .5, .5, 1);
    }
    

    And if we do that, now things run in perfect lock-step, exactly as intended:

    animate.addEventListener(`click`, () => {
      group.classList.add(`animate`);
      setTimeout(() => group.classList.remove(`animate`), 3000);
    });
    * {
      margin: 5px;
    }
    
    #group {
      --base: 0px;
      --end: 400px;
      .box {
        width: 50px;
        height: 50px;
        background: black;
        transform: translateX(var(--base));
        &.second {
          --base: calc(var(--end) / 2);
        }
      }
      &.animate {
        .box {
          animation-name: move;
          animation-fill-mode: forwards;
        }
        .first {
          animation-duration: 1s;
          animation-timing-function: cubic-bezier(.5, 0, .5, 1);
        }
        .second {
          animation-delay: 0.5s;
          animation-duration: 0.5s;
          animation-timing-function: cubic-bezier(.25, .5, .5, 1);
        }
      }
    }
    
    @keyframes move {
      from {
        transform: translateX(var(--base));
      }
      to {
        transform: translateX(var(--end));
      }
    }
    <button id="animate">animate</button>
    
    <div id="group">
      <div class='first box'></div>
      <div class='second box'></div>
    </div>