Yihang Ho bio photo

Yihang Ho

Coder

Twitter Github Email

TL;DR: We want to achieve this animation effect:

jQuery UI calls such animation the blind effect. For the lack of a better word, we will continue to use this term.

Ideally, we want to do this with as little JavaScript as possible and delegate the bulk of the work to the browser via CSS. If we think about this, all we want is to animate the height of the list between 0 and auto. Long story cut short, this doesn't work:

See the Pen Blind - Naïve Attempt by Yihang Ho (@yihangho) on CodePen.

The problem here is that the browser can't animate to and from an auto value. Hence, our approach is really close. The only problem is that we need to replace auto with a number.

However, we know that there is no reliable way to come up with such a number, even if the content of the list is fixed. One possibility is to use the max-height property instead - we animate between 0 and some large number:

See the Pen Blind - Kinda Work by Yihang Ho (@yihangho) on CodePen.

If we play with that a little, we will notice that the hiding animation takes a while to start and the transition takes shorter time than what we specified. (To make this more obvious, we can increase the animation duration.) Well, the problem is that, in this case, we are animating between 0 and 999. The actual height of the list is probably around 50px. Assuming a linear transition, almost 90% of the transition duration is wasted. We could make this work much better by using a smaller upper bound, but again, this is very dicey and can break very easily.

Instead of hardcoding a max height, we can compute the actual height using JavaScript. To perform the hiding animation, we first explicitly set the max height to the current height, then animate that to 0. In the process, we memoize the actual height. To show the list, we will animate the max height from 0 to the memoized actual height. Rough sketch of how we can do this:

var memoizedHeight;
button.addEventListener('click', function() {
  if (list.style.maxHeight === '0px') {
    list.style.maxHeight = memoizedHeight + 'px';
  } else {
    memoizedHeight = list.offsetHeight;
    list.style.maxHeight = memoizedHeight + 'px';

    requestAnimationFrame(function() {
      list.style.maxHeight = '0px';      
    });
  }
});

One interesting point is the use of requestAnimationFrame. In short, requestAnimationFrame will call the specified callback function before the next repaint. By then the max height of memoizedHeight will have been in effect and the browser can animate from that value to 0.

There is one small edge case for this solution. What happens if a reflow happens such that the actual list height changes while the list is hidden? Well, the animation will not be accurate. Worse, it might clip the content of the list if the list becomes taller.

A remedy to that is to listen to the transitionend event and remove the explicitly-set max height. In such cases, the content will always be shown, but there might be a visible "jump":

See the Pen Blind - Still Not There Yet by Yihang Ho (@yihangho) on CodePen.

To fix our problem, what we need is the ability to know the actual height of the list before we show the list. However, we can't rely on offsetHeight again as its value has been completely obliterated when we set the max height to 0. One possible solution is to wrap the list in a container div and perform the animation on the list. The value of offsetHeight of the children, i.e. the list, will be preserved even when the container div is hidden. The result is as follows:

See the Pen Blind by Yihang Ho (@yihangho) on CodePen.

Notes:

  1. We use a data attribute to keep track of the animation state. Technically this is not needed as it is possible to deduce the state from various other properties.
  2. We still listen to the transitionend to unset the max height property. This is to ensure that if the height of the list changes while it is shown, it will be reflected.