DEV Community

Alex MacArthur
Alex MacArthur

Posted on • Originally published at macarthur.me on

Using Forced Reflows, the Event Loop, and the Repaint Cycle to Slide Open a Box

If you're reading this, there's a more-than-zero chance you've used a CSS transition on max-height to slide open a box. You reached for max-height instead of height because the former will work when the box is sitting at its natural, unspecified height. The latter will not. As long as your max-height is greater than the actual height of the box, you're fine. In many cases, there’s no issue with this trick.

Still, I've always considered it a "trick" because it's not very deterministic. You're taking a best guess at what the "open" height should be, which could lead to problems. Example: here's a box, a little CSS, and some JavaScript to apply a new max-height:

<button id="button">Open Box</button>

<div id="box">
  <svg>⭐</svg>
</div>

<style>
  #box {
    max-height: 0;
    overflow: hidden;
    transition: max-height 0.25s;
  }

  #box.is-open {
    max-height: 300px;
  }
</style>

<script>
  const box = document.getElementById('box');
  const button = document.getElementById('button');

  button.addEventListener('click', () => {
    box.classList.toggle('is-open');
  });
</script>
Enter fullscreen mode Exit fullscreen mode

If you're not careful, the "open" height may not be large enough based on the box's contents. You could end up clipping it. Or, if you overshoot, the browser will execute your transition duration based on the max-height – not actual height, causing it to appear faster than intended.

opening a box with too short of a max-height

opening a box with too large of a max-height

Good news: there's a way to avoid this guessing game (actually, there are many, but we're exploring this one). It just requires us to measure and resize at precisely the right time.

Calculating the Rendered Box Height

Let's start with this setup: a totally hidden box, a button some CSS, and a click event listener. Let’s get it to slide it open.

<button id="openButton">Open</button>

<div id="box">
  <!-- box contents --> 
</div>

<style>
#box {
  display: none;
  overflow: hidden;
  transition: height 1s;
}
</style>

<script>
  const openButton = document.getElementById('openButton');
  const box = document.getElementById('box');

  openButton.addEventListener('click', () => {
    // Magic here. 
  });
</script>
Enter fullscreen mode Exit fullscreen mode

Before we can get the box’s “open” height, we'll need to allow the browser to render it as if it's open without painting it to the screen (we don't want any odd UI jerking). Fortunately, the browser's event loop makes that possible. Let's make it visible and get that height.

openButton.addEventListener('click', () => {
  box.style.display = 'block';
  const targetHeight = box.clientHeight;
});
Enter fullscreen mode Exit fullscreen mode

The moment we ask for clientHeight, a DOM reflow/recalculation is immediately, synchronously forced, causing elements on the page to be remeasured and repositioned. This makes it possible to get the rendered height before any pixels change on the screen. In fact, it's impossible for the browser to make anything visually change until that click handler is finished and control over the event loop is yielded back to the browser. That's the gift and curse of JavaScript – the event loop only allows one single thing to happen at a time.

Let's keep going. Next, we'll set the "starting" state of our animation by setting its height to 0px.

openButton.addEventListener('click', () => {
  box.style.display = 'block';
  const targetHeight = box.clientHeight;
  box.style.height = '0px';
});
Enter fullscreen mode Exit fullscreen mode

At this point, we can finally queue up the animation itself. But we'll need to do so in a particular way. The browser attempts to be efficient when the DOM is modified, and if we set attributes right after another, those changes will be batched into a single reflow. There would be a single repaint, and no animation.

openButton.addEventListener('click', () => {
  box.style.display = 'block';
  const targetHeight = box.clientHeight;
  box.style.height = `0px`;

  // Meaningless!
  box.style.height = targetHeight;
});
Enter fullscreen mode Exit fullscreen mode

To trigger an animation, we'll need to update the box's height across reflows.

Forcing an Immediate, Synchronous Reflow

Accessing clientHeight isn't the only way to force a synchronous reflow. There are a ton of properties that must perform one in order to give you an accurate value. Paul Irish has a big list of them. We can force a reflow between setting the height to 0 and the rendered height by doing this:

openButton.addEventListener("click", () => {
  box.style.display = "block";
  const targetHeight = box.clientHeight;
  box.style.height = `0px`;

  // Forced reflow.
  box.offsetHeight;

  box.style.height = `${targetHeight}px`;
});
Enter fullscreen mode Exit fullscreen mode

That's it. Just accessing the offsetHeight property forces a reflow and enables our animation. We could make it even more succinct too, with one fewer forced reflow.

openButton.addEventListener('click', () => {
  box.style.display = 'block';
  box.style.height = `0px`;

  // `scrollHeight` forces a synchronous reflow!
  box.style.height = box.scrollHeight;
});
Enter fullscreen mode Exit fullscreen mode

After setting the height to 0, we immediately set it to the element's scrollHeight, which allows us to measure the natural box size even when we're explicitly setting the height (another weird quirk of JavaScript in the browser). And accessing that property inadvertently causes a reflow before the DOM is updated with the new height. Those two height updates occur across reflows, and we get an animation. Voilà.

Now, let's push a little further. It's possible to do all this a smidge more optimally, and more in concert with how the browser paints stuff to the screen. We'll just need to dance with the event loop a bit.

Deferring Until Natural DOM Reflows

The browser is always orchestrating when it's appropriate to schedule reflows and paint those updates to the screen, and it comes with a tool to tap into that process: requestAnimationFrame().

Any callback passed to it will execute just before a reflow and repaint. That's why it's often used to measure how long layout changes take, and also why you might see some yellow followed by purple in your browser's performance tools:

browser performance report

With this, we can defer setting the new box height until just before the next repaint:

openButton.addEventListener("click", () => {
  box.style.display = "block";
  const targetHeight = box.clientHeight;
  box.style.height = `0px`;

  requestAnimationFrame(() => {
    box.style.height = box.scrollHeight;
  });
});
Enter fullscreen mode Exit fullscreen mode

The advantages of requestAnimationFrame() are more emphasized in complex animations without CSS transitions, but there is a teeny perk in cases like this too: we wait to force a reflow until the browser is ready to do something with it. You can see this play out in the browser's performance tools. Here's our earlier version without using an rAF. Notice how layout recalculation (purple) occurs as scrollHeight is accessed, and right in the middle the click event handler.

performance without requestAnimationFrame()

But the reflow is deferred when queued up inside requestAnimationFrame(), allowing the click handler to wrap up and give up control of the event loop a little sooner.

using requestAnimationFrame() defers reflow

Moreover, if the tab becomes inactive, the rAF is paused, pushing the reflow until it's absolutely necessary. That's very nit-picky, but responsible DOM management.

Sometimes, Defer Until After Repaint

You can't easily just tell which element properties trigger a synchronous reflow, and so you might end up in a position where a single requestAnimationFrame() doesn't trigger the animation like you'd expect. Here's an example that doesn't rely on a scrollHeight access. Instead, it computes the new height early on, and assigns it just before a repaint.

openButton.addEventListener("click", () => {
  box.style.display = "block";
  const targetHeight = box.clientHeight;
  box.style.height = `0px`;

  // Might not work consistently in all browsers:
  requestAnimationFrame(() => {
    box.style.height = `${targetHeight}px`;
  });
});
Enter fullscreen mode Exit fullscreen mode

This'll work in some browsers, but since updating the style attribute of an element doesn't force an immediate reflow, others may batch that update into a single paint. Just a flash. No animation. I verified this in Firefox v133, and this poor fellow ran into it as well.

The solution is to assign the new height after a fresh repaint. We can do that by scheduling the height change for the future while inside requestAnimationFrame(). Since this callback will fire just before a repaint, anything queued within it must occur after the repaint is finished.

You could schedule that next task with something like setTimeout() or scheduler.postTask(), but neither does so with regard to the repaint cycle. So, we'll just... use requestAnimationFrame() again.

openButton.addEventListener("click", () => {
  box.style.display = "block";
  const targetHeight = box.clientHeight;
  box.style.height = `0px`;

  // Nested rAFs:
  requestAnimationFrame(() => {
    requestAnimationFrame(() => {
      box.style.height = `${targetHeight}px`;
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Now, our height will change after a repaint has occurred, preventing batching, while still in harmony with browser repaints. And even in Firefox, the animation is buttery smooth. Heckuva lot more reliable than guessing a maximum height.

Before I Forget

I failed to mention there's an alternative way to do all of this faster, with less code and fewer gotchas: The Web Animations API. Set the frames you'd like to animate between and you're off to the races. The browser will manage all the nitty gritty stuff.

openButton.addEventListener('click', () => {
  box.style.display = 'block';

  box.animate([{ height: '0px' }, { height: `${box.clientHeight}px` }], {
    duration: 500,
  });
});
Enter fullscreen mode Exit fullscreen mode

Closing that box is simple too. Just reverse the list of frames.

function getFrames() {
  return [{ height: '0px' }, { height: `${box.clientHeight}px` }];
}

openButton.addEventListener('click', () => {
  box.style.display = 'block';

  box.animate(getFrames(), {
    duration: 500,
  });
});

closeButton.addEventListener('click', () => {
  const animation = box.animate(getFrames().toReversed(), {
    duration: 500,
  });

  animation.onfinish = () => {
    box.style.display = '';
  };
});
Enter fullscreen mode Exit fullscreen mode

It's OK. All that other stuff is really important to know. It'll help you appreciate the challenges the WAAPI is positioned to solve, and hopefully bring some more insight into just how weird & complicated the browser is. That's worth something.

Top comments (0)