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.
heightfor simplicity, but everything here also applies to
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.
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
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.
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.
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.
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:
- 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.
- 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.
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.
Managing a CSS transition in CSS would be ideal, but as we’re learning, sometimes it just isn’t entirely possible.
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.
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
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
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
border-boxin 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
transitionfor 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
heightstyle to get out of sync with its
data-collapsedattribute, 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.
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.
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!
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.