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>
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.
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>
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;
});
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';
});
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;
});
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`;
});
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;
});
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:
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;
});
});
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.
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.
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`;
});
});
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`;
});
});
});
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,
});
});
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 = '';
};
});
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)