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

  • The answer specific to the values used in the question:

    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>

    What if I need a different interval?

    The standard ease-in-out curve has exactly three "convenient" points where x, y, and t all line up: 0, 0.5, and 1. So what if you need a locked animation for a different interval than (0, 0.5) or (0.5, 1)?

    Bad news: we need more math.

    Good news: it's more math, but not necessarily more complex math.

    Say we want to animate a box in lockstep with our "main box" where our new box starts 25% along the way, and ends 75% along the way? Now we're going to have to work our way back because if we look at the curve diagram for that:

    enter image description here

    This is our ease-in-out function plotted with the "y" axis representing the "retimed value" and the "x" axis representing linear time (e.g. what CSS uses as input). We know which positions on the page we want to synchronize, so we're going to need to do three things:

    1. find the Bezier timing values for y=0.25 and y=0.75,
    2. use those to find the x values associated with our y=0.25 and y=0.75 so we can use those to set appropriate CSS animation delay and duration, and
    3. split the Bezier curve so that we only get the section of curve over the interval y=0.25 to y-0.75, which we can then scaled so that it start at (0,0) and ends at (1,1).

    The first one is relatively simple as long as we don't have to write the code ourselves, because it's mathematically identical to finding the roots of "only the bezier y function" at the y values 0.25 and 0.75.

    Cubic Bezier functions use four coefficients (let's call them a, b, c, and d) that we can control, a timing value that runs from 0 to 1, and a fixed basis function

    B(t,a,b,c,d) = a*(1-t)^3 + 3*b*t*(1-t)^2 + 3*c*(1-t)*t^2 + d*t^3
    

    Since our original function has (x,y) control coordinates (0,0), (0.5,0), (0.5,1) and (1,1), our "y only" function uses coefficients (a=0,b=0,c=1,d=1), and we're left with a situation where we need to solve:

    -y * (1-t)^3 - 3 * y * (1-t)^2 * t + 3 * (1-y) * (1-t) * t^2 + (1-y) * t^3 = 0
    

    for y values 0.25 and 0.75... you don't want to do this yourself, this is what computers are for, so we simply steal the code from the Primer on Bézier curves and move on:

    function solve(y) {
      // In theory a quartic curve can have up to three roots, with
      // two of them being complex numbers, but we're not dealing with
      // complex roots for this function: there's going to be one,
      // and it's going to be a "normal" real number.
      return getCubicRoots(-y, -y, 1-y, 1-y)[0];
    }
    
    function getCubicRoots(pa, pb, pc, pd) {
      const pi = 3.1415
      let a = (3 * pa - 6 * pb + 3 * pc);
      let b = (-3 * pa + 3 * pb);
      let c = pa;
      let d = (-pa + 3 * pb - 3 * pc + pd);
    
      // do a check to see whether we even need cubic solving:
      if (approximately(d, 0)) {
        // this is not a cubic curve.
        if (approximately(a, 0)) {
          // in fact, this is not a quadratic curve either.
          if (approximately(b, 0)) {
            // in fact in fact, there are no solutions.
            return [];
          }
          // linear solution
          return [-c / b].filter(accept);
        }
        // quadratic solution
        let q = sqrt(b * b - 4 * a * c);
        let a2 = 2 * a;
        return [(q - b) / a2, (-b - q) / a2].filter(accept)
      }
    
      // at this point, we know we need a cubic solution.
    
      a /= d;
      b /= d;
      c /= d;
    
      var p = (3 * b - a * a) / 3,
        p3 = p / 3,
        q = (2 * a * a * a - 9 * a * b + 27 * c) / 27,
        q2 = q / 2,
        discriminant = q2 * q2 + p3 * p3 * p3;
    
      // and some variables we're going to use later on:
      var u1, v1, root1, root2, root3;
    
      // three possible real roots:
      if (discriminant < 0) {
        var mp3 = -p / 3,
          mp33 = mp3 * mp3 * mp3,
          r = sqrt(mp33),
          t = -q / (2 * r),
          cosphi = t < -1 ? -1 : t > 1 ? 1 : t,
          phi = acos(cosphi),
          crtr = cuberoot(r),
          t1 = 2 * crtr;
        root1 = t1 * cos(phi / 3) - a / 3;
        root2 = t1 * cos((phi + 2 * pi) / 3) - a / 3;
        root3 = t1 * cos((phi + 4 * pi) / 3) - a / 3;
        return [root1, root2, root3].filter(accept);
      }
    
      // three real roots, but two of them are equal:
      if (discriminant === 0) {
        u1 = q2 < 0 ? cuberoot(-q2) : -cuberoot(q2);
        root1 = 2 * u1 - a / 3;
        root2 = -u1 - a / 3;
        return [root1, root2].filter(accept);
      }
    
      // one real root, two complex roots
      var sd = sqrt(discriminant);
      u1 = cuberoot(sd - q2);
      v1 = cuberoot(sd + q2);
      root1 = u1 - v1 - a / 3;
      return [root1].filter(accept);
    }
    
    function accept(t) {
      return 0 <= t && t <= 1;
    }
    
    function cuberoot(v) {
      if (v < 0) return -pow(-v, 1 / 3);
      return pow(v, 1 / 3);
    }
    
    function approximately(a, b) {
      return abs(a - b) < 0.00001;
    }
    

    Cool, that's effort we don't need to expend and instead we can now simply find t values for any y value we need by running solve(...). Which gives us:

    t25 = 0.3262301623603625
    t75 = 0.6735265150440621
    

    This means we can also quickly solve the second part: getting our x values, since our x coefficients are (0,0.5,0.5,1), so we know:

    x25 = 0.3644254753165319
    x75 = 0.6353700215114568
    

    We're almost there: we can now split the curve between the two t values we found, and then scale those values so that they run from (0,0) to (1,1), which gives us our new timing function:

    cubic-bezier(
      0.359154797859971, 0.30534859145837845,
      0.640953520598731, 0.6945340153510091
    );
    

    And now we have everything we need to set up a locked animation where the main box moves "as before" and our new box moves "in lockstep with that" but starting a quarter in, and ending a quarter out:

    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: 100px;
          --end: 300px;
        }
      }
      &.animate {
        .box {
          animation-name: move;
          animation-fill-mode: forwards;
        }
        .first {
          animation-duration: 1s;
          animation-timing-function: cubic-bezier(.5, 0, .5, 1);
        }
        .second {
          /* This is our "x at y=0.25" value: */
          animation-delay: 0.3644254753165319s;
          /* This is our "x at y=0.75 *minus* x at y=0.25" value: */
          animation-duration: calc(0.6353700215114568s - 0.3644254753165319s);
          /* And this is our new timing curve */
          animation-timing-function: cubic-bezier(0.359154797859971, 0.30534859145837845, 0.640953520598731, 0.6945340153510091);
        }
      }
    }
    
    @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>