Using CSS Transitions on Auto Dimensions

We've all been there. You've got an element you want to be able to collapse and expand smoothly using CSS transitions, but its expanded size needs to be content-dependent. You've set transition: height 0.2s ease-out. You've created a collapsed CSS class that applies height: 0. You try it out, and... the height doesn't transition. It snaps between the two sizes as if transition had never been set. After some fiddling, you figure out that this problem only happens when the height starts out or ends up as auto. Percentages, pixel values, any absolute units work as expected. But all of those require hard coding a specific height beforehand, rather than allowing it to naturally result from the size of the element content.

Nikita Vasilyev documented this well.
In this article I mostly speak in terms of height for simplicity, but everything here also applies to width.

If you were hoping I had a magical, complete solution to this problem, I'm sorry to disappoint you. There's no one solution that achieves the desired effect without downsides. There are, however, multiple workarounds that each come with a different set of advantages and disadvantages, and in most use cases at least one of them will get the job done in an acceptable manner. I'll outline the major ones, and list out their ups and downs so you can hopefully pick the best one for your situation.

Why hasn't this problem been fixed at the browser level?

According to the Mozilla Developer Network docs, auto values have been intentionally excluded from the CSS transitions spec. It looks like it's been requested by a few people, but when you think about it, it makes at least a little sense that it hasn't been included. The browser process that re-calculates the sizes and positions of all elements based on their content and the way they interact with each other (known as "reflow") is expensive. If you were to transition an element into a height of auto, the browser would have to perform a reflow for every stage of that animation, to determine how all the other elements should move. This couldn't be cached or calculated in a simple way, since it doesn't know the starting and/or ending values until the moment the transition happens. This would significantly complicate the math that has to be done under the hood and probably degrade performance in a way that might not be obvious to the developer.

Technique 1: max-height

If you web search this problem, the max-height approach will probably be mentioned in all of the first five to ten results. It's actually pretty unideal, but I thought it was worth including here for the sake of comparison.

It works like this: CSS values can only be transitioned to and from fixed unit values. But imagine we have an element whose height is set to auto, but whose max-height is set to a fixed value; say, 1000px. We can't transition height, but we can transition max-height, since it has an explicit value. At any given moment, the actual height of the element will be the maximum of the height and the max-height. So as long as max-height's value is greater than what auto comes out to, we can just transition max-height and achieve a version of the desired effect.

See the Pen Smooth Collapsing div with max-height by Brandon (@brundolf) on CodePen.

There are two crucial downsides to this

One is obvious, and one is subtle. The obvious disadvantage is that we still have to hard-code a maximum height for the element, even if we don't have to hard-code the height itself. Depending on your situation, maybe you can guarantee that you won't need more height than that. But if not, it's a pretty big compromise. The second, less obvious downside, is that the transition length will not actually be what you specify unless the content height works out to be exactly the same as max-height. For example, say your content is 600px tall, and your max-height is transitioning from 0px to 1000px with a duration of 1 second. How long will it take the element to get to 600px? 0.6 seconds! The max-height will continue transitioning, but the real height will stop changing once it reaches the end of its content. This will be even more pronounced if your transition is using a nonlinear timing function. If the transition is fast at the beginning and slow at the end, your section will expand quickly and collapse slowly. Not ideal. Still, transitions are relatively subjective, so in cases where this technique is otherwise appropriate, it could be an acceptable tradeoff.

Technique 2: transform: scaleY()

If you aren't familiar with the transform property, it allows you to apply GPU-driven transformations (translate, scale, rotate, etc.) to an element. It's important to note a couple of things about the nature of these transformations:

  1. They operate on the element's visual representation as if it were simply an image, rather than a DOM element. This means, for example, that an element scaled up too far will look pixellated, since its DOM was originally rendered onto fewer pixels than it now spans.
  2. They do not trigger reflows. Again, the transform doesn't know or care about the element's DOM structure, only about the "picture" the browser drew of it. This is both the reason this technique works and its biggest downside.

Implementation works like this: we set a transition for the element's transform property, then toggle between transform: scaleY(1) and transform: scaleY(0). These mean, respectively, "render this element at the same scale (on the y axis) that it starts out at" and "render this element at a scale of 0 (on the y axis)". Transitioning between these two states will neatly "squish" the element to and from its natural, content-based size. As a bonus, even the letters and/or images inside will visually "squish" themselves, rather than sliding behind the element's boundary. The downside? Since no reflow is triggered, the elements around this element will be completely unaffected. They will neither move nor resize to fill in the empty space.

See the Pen Smooth Collapsing div with scaleY() by Brandon (@brundolf) on CodePen.

The advantages and disadvantages of this approach are stark

It will either work very well for your use-case or won't be appropriate at all.

This mainly depends on whether or not any elements follow the one in question in the flow of the document. For example, something that floats over the main document like a modal or a tooltip will work perfectly this way. It would also work for an element that's at the bottom of the document. But unfortunately, in many situations, this one won't do.

Technique 3: JavaScript

Managing a CSS transition in CSS would be ideal, but as we're learning, sometimes it just isn't entirely possible.

If you absolutely have to have smoothly collapsing sections, whose expanded size is completely driven by their content, and which other elements on the page will flow around as they transition, you can achieve that with some JavaScript.

The basic strategy is to manually do what the browser refuses to: calculate the full size of the element's contents, then CSS transition the element to that explicit pixel size.

See the Pen dvoGyw by Brandon (@brundolf) on CodePen.

Let's deconstruct this a little bit. The first thing to note is that we keep track of whether or not the section is currently collapsed using the data-collapsed attribute. This is necessary so we know what to "do" to the element each time its expansion is toggled. If this were a React or Angular app, this would be a state variable.

The next thing that might stand out is the use of requestAnimationFrame(). This allows you to run a callback the next time the browser re-renders. In this case, we use it to wait to do something until the style we just set has taken effect. This is important where we change the element's height from auto to the equivalent explicit pixels value because we don't want to wait on a transition there. So we must clear the value of transition, then set height, then restore transition. If these were sequential lines in the code, the result would be as if they'd all been set simultaneously since the browser doesn't re-render in parallel to Javascript execution (at least, for our purposes).

The other idiosyncrasy is where we set height back to auto once the expansion has finished happening. We register an event listener with transitionend, which fires whenever a CSS transition concludes. Inside of that event listener, we remove it (since we only want it to respond to the immediately following transition), then remove height from the inline styles. This way, the element size is back to being defined however the normal styles for the page define it. We don't want to assume that it should remain the same pixel size, or even that it should remain auto sized. We want our JavaScript to perform the transition, and then get out of the way and not interfere more than necessary.

The rest is fairly straightforward. And, as you can see, this achieves exactly the desired result. That said, despite best efforts, there are quite a few ways in which this makes our code more brittle and potentially bug-prone:

  • We've added 27 lines of code instead of 3
  • Changes to things like padding or border-box in our section element could require changes to this code
  • CSS transitions on the section, that happen to end while the height transition is still going, could cause height not to be returned to its default value
  • Disabling transition for one frame could disrupt other transitions on that element which happen to be going at the same time
  • If a bug ever caused the element's height style to get out of sync with its data-collapsed attribute, its behavior could have problems

On top of all that, the code we've written is procedural instead of declarative, which inherently makes it more error-prone and complex. All that said, sometimes our code just needs to do what it needs to do, and if it's worth the tradeoffs then it's worth the tradeoffs.

Bonus Technique: Flexbox

I call this technique a bonus because it doesn't technically achieve the desired behavior. It offers an alternate way of determining your elements' sizes which in many cases may be a reasonable replacement, and which does fully support transitions.

You may want to read about flexbox and flex-grow before reading this section, if you're not familiar with them already.

Flexbox is an extremely powerful system for managing the way your interface's sizing adapts to different situations. Many articles have been written about this, and I won't go into it in detail. What I will go into, is the lesser-mentioned fact that the flex property and others related to it fully support transitions!

See the Pen Smooth Collapsing div with Flexbox by Brandon (@brundolf) on CodePen.

What this means, is that if your use case allows you to determine sizing using flexbox instead of your content size, making a section smoothly collapse is as simple as setting transition: flex 0.3s ease-out and toggling flex: 0. Still not as good as being content-based, but more flexible (I know, I know, I'm sorry) than going to and from pixel sizes.