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:
- "How can I transition height: 0; to height: auto; using CSS?"
- "Using CSS Transitions on Auto Dimensions"
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:
- Completely independent of JavaScript — including state toggling.
- Content height-agnostic.
- Reversible animation.
- Isochronous forward and backward animations.
- Animations can take advantage of GPU acceleration.
- When
relative
-positioned, toggling between the expanded and collapsed states should induce content reflow. - 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 transition
s 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:
- An outer container without a specific height that wraps an inner container.
- A
relative
-positioned inner container element that encapsulates the "animated" content. - An
absolute
-positioned pseudoelement that initially hides all content. - Using
translate()
orscale()
on the pseudoelement to make the content visible or hidden with transitions.
The role of the inner container is two-fold:
- To provide a context for
100%
height
to the pseudoelement. - To mitigate the instantaneous collapsing of all elements for the collapse animation.
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.