DOM measurements, box-sizing, and padding with React Collapsed
One of the longest standing bugs I've encountered with
useCollapse is a never-ending expanding animation when there's padding applied to the collapsed element.
useCollapse is a custom hook for creating accessible, animated collapse components.
I've exhausted countless hours on this one edge case bug, where if padding is applied to the collapse element, the animation will just never end: it keeps growing!
What's going on here? Well, it has to do with how the hook attempts to accommodate content changes within the collapse element. When the transition ends, we do a check to make sure that the height of the element matches the height we set the element to animate to before the transition began:
At the end of the animation, we measure the element again, and compare the element's height to the height we animated to:
height === styles.height. If they're not identical, we set the height to the element's current height, hoping to eventually reach a point where the element's dimensions stop fluctuating and settle.
When the collapse element does not have vertical padding applied, this solution works pretty well, and the animation will reliably settle. However, when vertical padding is applied, we get the bouncing accordion in the sandbox above, as the function compares over and over again the current element's height and the height it intended to animate to.
In the hours spent debugging this, I considered a number of variables. Was
scrollHeight the incorrect way to be measuring height? Was
offsetHeight more appropriate? (I concluded no,
scrollHeight should in fact be accounting for the element's true dimensions, including padding.)
Did I need to explicitly account for padding in the measurement? One peculiarity I was finding was that at that moment in the transition lifecycle,
offsetHeight produced the number closest to the actual height of the element, and
offsetHeight were actually returning not the height of the element, but just the padding! So maybe I needed to add the two together?
This also never worked, and the infinite animation continued.
I then also considered that using the
onTransitionEnd callback was flawed. While
onTransitionEnd seemed like the perfect lifecycle hook for detecting when the transition ended (surprise!), maybe it would be better to use a
setTimeout given the duration of the animation. I tried refactoring useCollapse with
setTimeout, and that just caused more compromises and challenges.
After hours and hours of fiddling with DOM measurement calculations, through refactors of the utility from component to hook, I'd just about given up. I'd added a "gotcha" section to the repo's documentation, and had added an error that would throw in runtime if the developer applied padding to the collapse element. I thought the fix didn't exist.
border-box, to the rescue!
Later on, when I was rewriting demos for v3 of
useCollapse, I came across a tiny style bug when using a
<div> element for the collapse toggle button (to show it can still be accessible without a
<button> element.) Lo and behold, making a
div resemeble a
button element is actually a little tricky, and I noticed a weird spacing issue, that when I set a fixed width on the toggle button, the
button element complied, but the
div did not! Aha, I said to myself: the issue was the element not having
box-sizing: border-box. And, of course, it was, and the
div element did then actually respect the width I had provided.
And then, a lightbulb went off!
box-sizing! I quickly added
box-sizing: border-box to the collapse element, and poof! the bug was gone!
First of all, what is
box-sizing? This property determines how the browser will calculate the total width and height of an element. The default value for
content-box, which constrains the measurement to only the element's content, not accounting for its
border-box, in contrast, includes that
Let's take a look at how that manifests:
Above, we measure the height of two elements in the same manner. Both have padding, and only one has the
box-sizing value changed from
content-box (the browser default) to
border-box. When we measure the height of element with
content-box, we get 100, which is the height we explicitly set on the element, and does not include the padding. The element with
border-box yields 120, which is both the height we set on the element, and includes the padding (10 on the top and bottom: 100 + 10 + 10 = 120).
Going back to
useCollapse, now when the transition ends and we calculate the height of the element, we're now actually measuring the true total height of the element and its content, so we get an accurate comparison, and the animation will finally end!
useCollapse today with padding applied to the element:
You'll notice that something still doesn't look so hot with that animation...
This is a problem that I think has no solution. When an element has a fixed height of 0, but still has padding, that padding will still be visible, even with
So when the collapse animation ends, we will get that flash.
But at least the animation will end!