Animating height: auto

(If you want to skip the background, go straight to the fun part of this post.)

Can you make it expand and collapse?

This is one of the most common requests in a complex user interface: make an HTML element expand and collapse on toggle. This is a great way to hide complexity and features that aren't frequently in use. In CSS, we can simply set display: none on the collapsed state and be done with it. In the following example we set the toggle state based on whether the checkbox is checked or not.

.demo01 {
background-color: #777;
display: none;
padding: 1rem;
}
:checked ~ .demo01 {
display: block;
}
<div>Something before 🙃</div>
<div class="demo01">
This content should…<br />
toggle! 👻
</div>
<div>Something after 🙂</div>

Toggle the checkbox to toggle the content:

Hold on, that's not quite it. That's… jarring.

What about transitioning between the two states? We would prefer a nice animation so that the user can keep track of what is happening by following the movement of elements on the screen. If we simply toggle between the states directly like we did above, it's easy to be unsure where a part of the content has gone. With large blocks of content, the user can easily get lost on the page.

An immediate idea is to give the collapsed state height: 0 and let browser handle the transition. Here's how that behaves:

.demo02 {
transition: height 1s;
height: 0;
visibility: hidden;
}
:checked ~ .demo02 {
height: auto;
visibility: visible;
}
<div>Something before 🙃</div>
<div class="demo02">
This content should…<br />
toggle! 👻
</div>
<div>Something after 🙂</div>

Uh… that did not trigger a transition…

Indeed. The problem is that by default the height of every element is determined automatically to fit the element's content (i.e. height: auto). And since auto is not a numeric value the browser cannot transition between 0 and auto. We have to provide a specific height for the expanded mode so it becomes animatable. In addition, you'll notice that even with height: 0 the padding of the element remains visible when collapsed, creating a gap. Maybe manipulating height isn't a great way to achieve this effect.

(Note that we set visibility: hidden on the collapsed state. This is a requirement to ensure the collapsed content is disregarded by screen readers and keyboard focus.)

The ways around it

CSS-Tricks has a good summary of some possible approaches to get around this limitation:

  • Use max-height with a "reasonably large" value instead of height
  • Use transform: scaleY()
  • Use Javascript to set the height

Of these, only Javascript provides the intuitive behaviour that someone imagines an expand/collapse transition should have; the other two approaches cause visual weirdness. Unfortunately there are also several caveats with the Javascript solution — not least, the fact that it requires Javascript. 😆 If you haven't yet, you really should read about it in that article.

The solutions

“That's silly. Isn' this sort of thing what CSS is for?”

Yep, it's frustrating that this is not achievable with CSS alone — or is it? Let's consider two use-cases:

  • We want to expand the content as an overlay on top of whatever is under — like a dropdown, for instance.
  • We want to expand content in the document flow, pushing whatever follows it further down the page.

Let's look at both cases:

Expanding/collapsing an overlay

Instead of adjusting height we could instead clip the element during the transition. Draw the whole element, but clip it at different heights while it animates. One way to achieve this is to move the element within a "viewport" container that has overflow: hidden. As our element slides out of the container's visible area, it will be clipped out of existence (and vice-versa). We can use transform: translateY(-100%) to move the content off-viewport, since percentage values are based on the dimensions of the content, and therefore they transition perfectly.

That video comes from @silentmoviegifs on Twitter. Anyway, let's clip some content!

.demo03 {
overflow: hidden;
}
.demo03__content {
transform: translateY(-100%);
transition: transform 1s, visibility 1s;
visibility: hidden;
}
:checked ~ .demo03 .demo03__content {
transform: translateY(0);
visibility: visible;
}
<div>Something before 🙃</div>
<div class="demo03">
<div class="demo03__content">
This content should…<br />
toggle! 👻
</div>
</div>
<div>Something after 🙂</div>

Well, ok, but what about that big gap?

That big gap is important — it's the viewport container. Because it gets its height from its content (i.e. from the element we want to clip), the container is sized perfectly to show the whole child element when it slides into place. All we have to do is remove the container from the document flow so it doesn't create a gap. We can do that by absolutely positioning it but — crucially — not anchoring it anywhere. The browser will keep it positioned in the same place, but it won't affect surrounding elements.

.demo04 {
overflow: hidden;
position: absolute;
}

As you can see in the example above if you enable the outline, the container remains in place permanently. Although it is transparent, it's important to disable pointer interaction (pointer-events) so that taps and clicks go "through" the container onto the elements below. We can then re-enable pointer-events on the sliding element so that it remains interactive when shown.

.demo05 {
pointer-events: none;
}
.demo05__content {
pointer-events: all;
}

Can you make it a 'wipe' effect instead of sliding?

A wipe effect, popular in the likes of PowerPoint, reveals the element without moving it: it looks like more of the element is being shown over time, like a cover is being moved out of the way to reveal our element.

We can't truly move a cover though, since it would need to be transparent to the background below — but we can repurpose our previous "slide" transition for a solution. By adding an extra wrapper around our content we can counter the negative translateY with a positive one. We use the first wrapper as before, to provide an absolute space to transition within. The second wrapper slides, creating an expanding "letterbox" slot through which the content can be seen. The content slides up while the second wrapper slides down, so the content appears to not move.

<div>Something before 🙃</div>
<div class="demo06">
<div class="demo06__letterbox">
<div class="demo06__content">
This content should…<br />
toggle! 👻
</div>
</div>
</div>
<div>Something after 🙂</div>
.demo06__letterbox {
overflow: hidden;
transform: translateY(-100%);
transition: transform 1s, visibility 1s;
visibility: hidden;
}
.demo06__content {
transform: translateY(100%);
transition: transform 1s;
}
:checked ~ .demo06 .demo06__letterbox {
transform: translateY(0);
visibility: visible;
}
:checked ~ .demo06 .demo06__content {
transform: translateY(0);
}

OK, cool! We can slide or wipe an overlay without knowing its height. Let's look at the next use case now.

Expanding/collapsing in the document flow

The previous solution worked because we had a container that was sized perfectly to fit its contents — this is default browser behaviour, after all. There was no actual resizing. To collapse/expand content in the document flow we can't do the same; we really need to change the rendered height of the content so that the following elements are moved up/down during the transition. But height is out.

Maybe we can reuse the idea from the overlay approach above, by having a container size itself around our content and then resize that.

There are layout models that operate on the contents of an element, set via the display property. Of these, table, flex, and grid all look promising, but grid has a few really interesting qualities:

  • We can use the fr unit to declare sizes based on available space ("available" means after sizing to fit its content)
  • fr values are numeric, hence we can animate/transition them
  • It has declarative layout on the container via grid-template-* properties (so the container will define the children's dimensions)

Let's give this a try:

<div>Something before 🙃</div>
<div class="demo07">
<div class="demo07__content">
This content should…<br />
toggle! 👻
</div>
</div>
<div>Something after 🙂</div>
.demo07 {
display: grid;
grid-template-rows: 0fr;
overflow: hidden;
transition: grid-template-rows 1s;
}
.demo07__content {
align-self: end;
min-height: 0;
transition: visibility 1s;
visibility: hidden;
}
:checked ~ .demo07 {
grid-template-rows: 1fr;
}
:checked + .demo07 .demo07__content {
visibility: visible;
}

Our grid has a single template row, and a single child (which means a single column, too). Since we don't specify a height in the container, it will be sized to fit its content. We then override that by specifying the row track height to be 0fr.

Here's the trick: the height of the child element is automatically set by the height of the grid track it belongs to. We need to set min-height: 0 to override the default setting of auto on grid items, so that the element can shrink to nothing as its track also shrinks.

We then set the track height back to 1fr, which should trigger a transition. Here's how this works:

Hold on! That didn't do anything!

If you only saw the element toggle without transition you are most likely using Chrome or Safari. Those browsers have a bug that causes the grid-template-rows property to not transition. Firefox, on the other hand, has no such problem!

Here's how that looks in Firefox:

Of course, Chrome-based browsers (plus Safari) account for nearly all of the browser share at the moment. This is a huge bummer, but since it's actually a bug it means it just needs fixing! Go poke the Chrome devs into doing that — the Chromium ticket is #759665. Please star it!

Update 2021/12/15: It seems like Microsoft devs have started working on fixing this issue in Chromium! 🤞

As a bonus, here's how we can perform a wipe effect also in the document flow — we simply anchor the content to the bottom of the grid track by removing align-self: end, so it is clipped from the bottom instead. Everything else is identical to the previous example.

.demo08__content {
/* align-self: end; */
}

So there you have it. Animating or transitioning the height of an element without knowing its height and using only CSS is possible. If the element is an overlay, the technique can be used right now. For flow content, we just need one browser bugfix to use this with confidence. (Go star that issue!)