Pure-CSS collapsible through the looking glass

When creating this website, one of the rabbit holes that I dived into is as the title describes. The title challenge was no stranger to me — and one may argue that it was, therefore, a revisit through the looking glass; however, what tempted me to spend more time on this puzzle was the following StackOverflow question that dates back to 2010: "How can I transition height: 0; to height: auto; using CSS?".

The accepted, most upvoted solution in the StackOverflow thread involves assigning an arbitrary value to the max-height property of an element. This reason alone should induce enough discomfort in the discerning programmer to at least refrain from assisting its propagation with copypasta, but I digress.

The purpose of this article is to review existing solutions and show some of the solutions I found in the rabbit hole. As the title indicates, this article covers pure-CSS solutions exclusively, not least because CSS solutions are a subset of those achievable with JavaScript.

While it is outside the scope of this article to discuss the practicality and utility of pure-CSS constructs, it is worth noting that they do have legitimate uses in both JavaScript-less and JavaScript-enabled environments. In fact, at the time of writing, the summary of the "CSS and JavaScript Animation Performance" article in the MDN Web Docs states that:

Browsers are able to optimize rendering flows. In summary, we should always try to create our animations using CSS transitions/animations where possible.

Last but not least, I consider creating pure-CSS constructs an art form practised under constraints that is not dissimilar to pixel art and 8-bit music: they are first and foremost brain teasers, and there is always much to learn and much fun to be had.

Disclaimer

This article is an academic exercise driven by curiosity and naivety. It does not promote the use of complex pure-CSS logic as a substitute for well-tested, maintainable, and accessible solutions.

A Note on References

The primary sources referenced for existing solutions are not necessarily the most original but chosen base on information density, the number of valuable insights, and ease of access:

Please do not hesitate to contact me should you be aware of missing or better references.

Problem Statement

Ideal

Ideally, we can create an interactive collapsible that has the following characteristics:

  1. Completely independent of JavaScript — including state toggling.
  2. Content height-agnostic.
  3. Reversible animation.
  4. Isochronous forward and backward animations.
  5. Animations can take advantage of GPU acceleration.
  6. When relative-positioned, toggling between the expanded and collapsed states should induce content reflow.
  7. When relative-positioned, any animation-induced document reflow should be continuous.

If I'm not mistaken, characteristics 5 and 7 are currently mutually exclusive. It is worth noting that I found James Steinbach's "Holy Grail of CSS Animation" after creating the list above. It is not my intention to one-up the difficulty by including mutually exclusive characteristics.

Reality

In reality, the most popular solutions suffer from the lack of content height-agnosticism or achieve height-agnosticism by sacrificing some of the other characteristics listed above.

Consequences

The consequences are guess-tistically insignificant to the general population. May occasionally surprise clients or inexperienced developers. In the most severe cases, it may lead to the composition of articles such as this one.

Proposal

Please refer to the relevant sections that follow.

Background

The problem in question stems from the fact that a method for a smooth transition to a value of auto is currently unavailable. Please refer to "Using CSS Transitions" article of the MDN Web Docs for a brief explanation, and the GitHub Issue "[css-transitions] Transition to Height (or width) auto" for possible development.

Prior Art

This part of the article describes existing methods according to the referenced implementation details. Solutions built upon these methods, or parts of these methods, could have very different characteristics — for example, the inclusion of a wrapper ongoing from method 2 to method 3.

Note that the discussion below focuses heavily on document reflow because the title problem is a non-problem for where document reflow is irrelevant.

Two-state Toggling

Toggling between two states is the preferred method for pragmatists. It is often implemented simply by toggling between display: none and display: block (applicable to any non-none value); or between height: 0 and height: auto. The use of the detail and summary HTML elements is similar to this method when it is semantically appropriate to use them.

<input id="toggle" type="checkbox" />

<div class="collapsible">
 <!-- Content -->
</div>
/* Collapsed */
.collapsible {
 display: none;
}

/* Expanded */
.toggle:checked ~ .collapsible {
 display: block;
}

CodePen demo: Two-state toggling.
Ideal characteristics: 1, 2, 3, 4, 6.

Predefined max-height

Transitioning the max-height property of an element to a predefined value is the most touted "solution" to the title problem. There is no shortage of information on this method; for this reason, please refer to "Using CSS Transitions on Auto Dimensions" for a detailed explanation of this method.

This method is objectively inferior in cases where the animation is outside of a document's normal flow—such as an absolute-positioned element — as there exist alternatives that are both more performant and are height-agnostic.

However, in cases where the animation is within a document's flow and a continuous transition is desired, this method offers unparalleled simplicity when used with a relatively short transition-duration. It is also worth noting that this method is relatively flexible as it does not rely on the support of a wrapper with a predefined height, unlike, for example, the flexbox method.

<input id="toggle" type="checkbox" />

<ul class="collapsible">
 <!-- Content -->
</ul>
/* Collapsed */
.collapsible {
 max-height: 0;
 transition: max-height 700ms;
}

/* Expanded */
.toggle:checked ~ .collapsible {
 max-height: 2048px;
}

CodePen demo: Predefined max-height.
Ideal characteristics: 1, 3, 4,* 6, 7. * When max-height equals to final height.
Reference: "How can I transition height: 0; to height: auto; using CSS?", answer by Jake.

The scale() Transformation Function

The relevant section in "Using CSS Transitions on Auto Dimensions" covers this method in detail. If deformed content during animation is not a concern, this method is effectively perfect when used outside of the document's flow in the context of the ideal characteristics above. On the other hand, this method is unsuitable where animation-triggered content reflow is required. A subtlety worth noting is that the transition from display: none to, for example, display: block is incompatible with this method.

<input id="toggle" type="checkbox" />

<ul class="collapsible">
 <!-- Content -->
</ul>
/* Collapsed */
.collapsible {
 transform: scaleY(0);
 transition: transform 700ms;
}

/* Expanded */
.toggle:checked ~ .collapsible {
 transform: scaleY(100%);
}

CodePen demo: The scale() Transformation Function.
Ideal characteristics: 1, 2, 3, 4, 5.
Reference: "Using CSS Transitions on Auto Dimensions".

The scale() Transformation Function and keyframes Animation

One way to circumvent the scale() transformation function's lack of effect on a document's flow is to use keyframes animation to separate the change in display and the transform animation. However, the referenced method sacrifices the reverse animation sequence by reducing it to a toggle between display: block and display: none.

<input id="toggle" type="checkbox" />

<ul class="collapsible">
 <!-- Content -->
</ul>
/* Collapsed */
.collapsible {
 transform: scaleY(0);
 transition: transform 700ms;
}

/* Expanded */
.toggle:checked ~ .collapsible {
 transform: scaleY(100%);
}

CodePen demo: The scale() Transformation Function and keyframes Animation.
Ideal characteristics: 1, 2, 5, 6.
Reference: "CSS Snippet for “Animating” both Display and Transform".

Encapsulated translate() Transformation Function

The translate() transformation function relies on the help of a wrapper element to hide part of the animated element from the user. The result is similar to that of the scale() Transformation Function and keyframes Animation method described above.

<input id="toggle" type="checkbox" />

<div class="container">
 <ul class="collapsible">
 <!-- Content -->
 </ul>
</div>
/* Collapsed */
.container {
 height: 0;
 overflow: hidden;
 transition: height 700ms;
}

.collapsible {
 transform: translateY(-100%);
 transition: transform 700ms;
}

/* Expanded */
.toggle:checked ~ .container {
 height: auto;
}

.toggle:checked ~ .container > .collapsible {
 transform: translateY(0);
}

CodePen demo: Encapsulated translate() Transformation Function.
Ideal characteristics: 1, 2, 3, 4, 5,* 6. * Depends on how the wrapper appears and disappears.
Reference: "Using CSS Transitions on Auto Dimensions", answer by Sijav.

Optical Illusion

One can consider this method an "improvement" upon the Two-state Toggling method. It involves applying transitions to the top and bottom paddings when toggling between the expanded and collapsed states. It is an optical illusion because the appearance and disappearance of the content are still instantaneous.

The pedantry expressed towards this method is perhaps somewhat undue: this use of optical illusion produces acceptable results when using a short transition-duration on content with a limited height.

<input id="toggle" type="checkbox" />

<ul class="collapsible">
 <!-- Content -->
</ul>
/* Collapsed */
.collapsible {
 display: none;
 padding: 0 0.6rem;
 transition: padding 700ms;
}

/* Expanded */
.toggle:checked ~ .collapsible {
 display: block;
 padding: 1.2rem 0.6rem;
}

CodePen demo: Optical Illusion.
Ideal characteristics: 1, 2,* 3,* 4,* 6, 7.* * Requires a balance between element height and transition duration.
Reference: "How can I transition height: 0; to height: auto; using CSS?", answer by Catharsis.

Flexbox

"Using CSS Transitions on Auto Dimensions" covers this method in detail. It is worth noting that this method is arguably the least flexible with height in this article, and its lack of height-agnosticism is the same as transitioning to an exact value using the Predefined max-height method at best.

<input id="toggle" type="checkbox" />

<div class="flex-container">
 <div class="flex-item">
 <!-- Content -->
 </div>

 <ul class="collapsible">
 <!-- Content -->
 </ul>

 <div class="flex-item">
 <!-- Content -->
 </div>
</div>
/* Collapsed */
.collapsible {
 margin: 0;
 flex: 0;
 transition: flex-grow 700ms;
 overflow: hidden;
 transition: all 1s;
}

/* Expanded */
.toggle:checked ~ .container > .collapsible {
 flex: 1;
}

/* Static */
.flex-container {
 display: flex;
 flex-direction: column;
 justify-content: flex-start;
 height: 400px;
}

.flex-item {
 flex: 1;
 transition: flex-grow 1s;
 border: 2px solid black;
}

CodePen demo: Flexbox.
Ideal characteristics: 1, 3, 4, 6, 7.* * Appears to be continuous for a small element with a short transition-duration.
Reference: "Using CSS Transitions on Auto Dimensions"

Transitioning Content Size with font-size and line-height

For textual and em-sized content, we can achieve height-agnostic transition by changing the font-size property. It is a very different animation compared to all other methods described in this article with the way characters grow and wrap. Transitioning the line-height property leads to similar results and has an animation that is similar to those of some of the other methods described.

<input id="toggle" type="checkbox" />

<div class="collapsible">
 <!-- Content -->
</div>
/* Collapsed */
.collapsible {
 font-size: 0;
 line-height: 0;
 transition: font-size 700ms, opacity 700ms;
}

/* Expanded */
#toggle:checked ~ .collapsible {
 font-size: 1rem;
 line-height: 1.5;
}

CodePen demo: Transitioning Content Size with font-size.
CodePen demo: Transitioning Content Size with line-height.
Ideal characteristics: 1, 2, 3, 4, 6, 7.
Reference: "How can I transition height: 0; to height: auto; using CSS?", answer by Steven Vachon, "How can I transition height: 0; to height: auto; using CSS?", answer by Ali Klein.

Encapsulated Content Duplication

This method involves wrapping two structurally identical elements in the same containing block and use one of the duplicates, which has visibility set to hidden, to give the other a context of what 100% height is.

Animations of the relative-positioned wrapper implemented this way do not trigger document reflow. It is a non-GPU-accelerated counterpart of simple CSS transformation functions with the drawback of requiring duplicated content.

<input id="toggle" type="checkbox" />

<div class="container">
 <ul class="collapsible">
 <!-- Content -->
 </ul>

 <ul class="duplicated">
 <!-- Duplicated content -->
 </ul>
</div>
/* Collapsed */
.collapsible {
 position: absolute;
 height: 0;
 overflow: hidden;
 transition: height 700ms;
}

/* Expanded */
.toggle:checked ~ .container > .collapsible {
 height: 100%;
}

/* Static */
.container {
 position: relative;
}

.duplicated {
 visibility: hidden;
}

CodePen demo: Encapsulated Content Duplication.
Ideal characteristics: 1, 2, 3, 4.
Reference: "How can I transition height: 0; to height: auto; using CSS?", answer by Vivek Maharajh

Percentage margin

These methods are usually flawed and based on a misunderstanding of how the browser evaluates percentage margins and paddings. We should avoid using percentage margins as a "height-agnostic" replacement for setting an arbitrary, predefined value to max-height; not least because doing so can lead to unexpected behaviours.

<input id="toggle" type="checkbox" />

<div class="container">
 <ul class="collapsible">
 <!-- Content -->
 </ul>
</div>
/* Collapsed */
.collapsible {
 margin-top: -100%;
 overflow: hidden;
 transition: margin-top 700ms;
}

/* Expanded */
.toggle:checked ~ .container > .collapsible {
 margin-top: 0;
}

/* Static */
.container {
 position: relative;
 overflow: hidden;
}

CodePen demo: Percentage margin.
Ideal characteristics: 1, 3, 4.
Reference: "How can I transition height: 0; to height: auto; using CSS?", answer by Vivek Maharajh

A Bit of Everything

Please refer to the referenced answer for a very detailed explanation of this method. The greatest strength of this method is that it addresses the problem of anisochronicity and apparent irreversibility in the Predefined max-height approach. However, it is not height-agnostic and relies on many more arbitrarily defined parameters than all other methods.

Ideal characteristics: 1, 3, 4, 6, 7.
Reference: "How can I transition height: 0; to height: auto; using CSS?", answer by balpha

Proposed Solutions

Below are some of the solutions that I found while I was in the rabbit hole. Please do not hesitate to contact me should you find existing documentation for any of them.

Double Encapsulation and Pseudoelement

I initially arrived at this approach when I attempted to create a pure-CSS, height-agnostic menu with a wipe animation for this website. This method requires a pseudoelemnt with the same background behind the content it hides, which is also the biggest downside of this method. Its implementation relies on the following:

The role of the inner container is two-fold:

The mitigation is a hack that functions by transitioning between height: 0 and height: 42% inside a containing block that does not have a specified height ("42%" is chosen for clarity: this method works for any positive, non-zero percentage value). Under these conditions, the collapse of the inner container occurs at the end of the transition. In contrast, the corresponding transition involving a pair of non-interpolable values, such as 0 and auto, would result in the inner container collapsing at the beginning of the transition.

<input id="toggle" type="checkbox" />

<div class="outer-container">
 <div class="inner-container">
 <div class="collapsible">
 <!-- Content -->
 </div>
 </div>
</div>
/* Collapsed */
.inner-container {
 height: 0;
 transition: height 700ms;
}

.collapsible::before {
 content: ' ';
 position: absolute;
 display: block;
 width: 100%;
 height: 100%;
 transform: scaleY(1);
 transform-origin: bottom;
 transition: transform 700ms;
}

/* Expanded */
#toggle:checked ~ .outer-container > .inner-container {
 height: 1%;
}

#toggle:checked ~ .outer-container > .inner-container > .collapsible::before {
 transform: scaleY(0);
}

CodePen demo: Double Encapsulation and Pseudoelement.
Ideal characteristics: 1, 2, 3, 4, 5, 6.

Encapsulated scale() Transformation Function

The proposed solution above relies on a pseudoelement to achieve a wipe effect without content deformation. With the scale() transformation, we can use one fewer container and do not need a pseudoelement if content deformation is not a concern.

<input id="toggle" type="checkbox" />

<div class="container">
 <div class="collapsible">
 <!-- Content -->
 </div>
</div>
/* Collapsed */
.collapsible {
 height: 0;
 transform: scaleY(0);
 transform-origin: top;
 transition: height 700ms, transform 700ms;
}

/* Expanded */
#toggle:checked ~ .container {
 height: 42%;
 transform: scaleY(1);
}

CodePen demo: Encapsulated scale() Transformation Function.
Ideal characteristics: 1, 2, 3, 4, 5, 6.

Encapsulated clip-path

This also does not require a pseudoelement and produces the same type of animation as the pseudoelement method. Transitioning the clip-path property is not hardware-accelerated in major browsers at the time of writing, but will likely become available in the future. Other than the current lack of support from hardware acceleration, this approach is much more versatile and elegant than the pseudoelement method.

<input id="toggle" type="checkbox" />

<div class="container">
 <div class="collapsible">
 <!-- Content -->
 </div>
</div>
/* Collapsed */
.collapsible {
 height: 0;
 clip-path: polygon(0 0, 100% 0, 100% 0, 0 0);
 transition: height 700ms, clip-path 700ms;
}

/* Expanded */
#toggle:checked ~ .container > .collapsible {
 height: 42%;
 clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
}

CodePen demo: Encapsulated clip-path.
Ideal characteristics: 1, 2, 3, 4, 6.

Enforcing Aspect Ratio

This method draws inspiration from the Aspect Ratio Boxes article on CSS Tricks. With this approach, we transition the width property of an element, which we usually have better control over compared to height. This method is partially height-agnostic in that it works well for non-overflowing content designed to fill one or both dimensions of the collapsible.

The code example below shows a more versatile version of this method that includes a container element. A container element is not necessary if the width of the collapsible in the fully expanded state is the same as its containing block.

<input id="toggle" type="checkbox" />

<div class="container">
 <div class="collapsible">
 <!-- Content -->
 </div>
</div>
/* Collapsed */
.container {
 max-width: 0;
 overflow: hidden;
}

.collapsible {
 height: 0;
 width: 100%;
 padding-top: 56.25%;
 background-image: url('...');
 background-position: center;
 background-repeat: no-repeat;
 background-size: contain;
 overflow: hidden;
}

/* Expanded */
#toggle:checked ~ .container {
 max-width: 400px;
}

Most modern browsers now support the aspect-ratio CSS property. Using this CSS property does not require a container, and the collapsible can accommodate any type of content without any position hacks.

<input id="toggle" type="checkbox" />

<div class="collapsible">
 <!-- Content -->
</div>
/* Collapsed */
.collapsible {
 max-width: 0;
 width: 100%;
 aspect-ratio: 16 / 9;
 background-image: url('...');
 background-position: center;
 background-repeat: no-repeat;
 background-size: contain;
 overflow: hidden;
 transition: visibility 700ms, max-width 700ms;
}

/* Expanded */
#toggle:checked ~ .collapsible {
 max-width: 400px;
}

CodePen demo: Enforcing Aspect Ratio with padding.
CodePen demo: Enforcing Aspect Ratio with aspect-ratio.
Ideal characteristics: 1, 2,* 3, 4, 6, 7. * When content doesn't overflow the available space
Reference: Aspect Ratio Boxes.

Concluding Remarks

There are broadly two categories of height-agnostic solutions described above: those exhibit ideal characteristics 7, and those do not. In the first category, I feel that the line-height and font-size methods are the most versatile when used together with em-sized content.

There are equally good approaches in the second category with different types of animation. My personal favourite is the clip-path method since it doesn't deform content and can accommodate different shapes. Once hardware acceleration is available for clip-path transitions, there is little to dislike about this method.

I have dabbled with other CSS properties such as writing-mode (for taking advantage of height-based percentage margin), clamp(), and CSS variables but failed to come up with anything meaningful. I feel that there are still better solutions than those already covered here. And the rabbit hole doesn't end there — not least because we could measure performance, accessibility, etc. — but the exploration has to stop somewhere.

As the information covered in this article is time-sensitive, I will only update this article to correct mistakes, add missing references, and add examples that use the same set of CSS features as when it was available at the time this was first published.