Transition to `height: auto` & `display: none` Using Pure CSS
Find out how to easily transition to intrinsic sizes and trigger transitions when an element receives its first style update using new CSS features.
calc-size
was used with a single argument, like this: calc-size(auto)
. This is no longer supported; calc-size()
works only as a two-argument form, so the article and all demos are updated to reflect this, and instead of calc-size(auto),
you should now use calc-size(auto, size)
.CSS Transitions are the easiest way to add interactions on the web; all you need is an element in two different states with the transition
property applied to its initial state, and the browser will smoothly animate the element between these two states.
The challenging part when working with CSS Transitions is dealing with intrinsic element sizes like auto
and running transitions when an element receives its first style update—when it’s added to DOM, on page load, or when its display value changes from none
. In this article and video, you’ll learn how to deal with both cases using upcoming CSS features: calc-size()
function, interpolate-size
property, @starting-style
at-rule, and transition-behavior
property.
CSS Transition to intrinsic size (height: auto;
)
Let’s say you want to create a disclosure widget that expands an element from its initial, closed state (height: 0;
) to an open state, showing all its content (height: auto;
).
A simplified setup for this could look something like this:
.disclosure-widget {
height: 0;
transition: all 0.7s ease-in-out;
}
.disclosure-widget[open] {
height: auto;
}
Initially, the widget is in a closed state with height: 0;
, and when the attribute open
is present on the HTML element, the widget expands to its content-based height. As the transition
property is defined on the widget, it would be reasonable to assume that this switch between states will be animated, but it doesn’t work.
This element’s height is expected to be smoothly animated as the transition
property is set, but it doesn't work.
height
property is used in the examples for clarity and simplicity, but you should be using its logical equivalent, block-size
. If you want to learn more about logical properties, check out my video, Guide to Logical CSS Properties.The animation doesn’t happen because browsers don’t support the transition to intrinsic sizing keywords such as auto
or min-content.
The new interpolate-size
property and calc-size()
function will allow you to circumvent this and perform math on intrinsic sizes in a safe, well-defined way.
CSS calc-size()
function
The calc()
CSS function lets you perform calculations when specifying CSS property values, with one of the best features being that you can mix various data types, like pixels and percentages. For example, width: calc(90% - 10px);
will give you exactly the value you’re looking for, 90% of the screen width reduced by 10px.
The downside of calc()
is that it doesn’t support calculations on intrinsic sizing keywords, including auto
, and that’s precisely why the new calc-size()
function was introduced—to allow calculations and thus transitions and animation to or from intrinsic sizes.
The CSS
calc-size()
function is a CSS function similar tocalc()
, but that also supports operations on exactly one of the valuesauto
,min-content
,max-content
,fit-content
,stretch
, orcontain
, which are the intrinsic sizing keywords. This allows transitions and animations to and from these values (or mathematical functions of these values), as long as thecalc-size()
function is used on at least one of the endpoints of the transition or animation to opt in.Explainer: calc-size() function for transitions and animations to/from intrinsic sizes
The calc-size
function takes two arguments. The first argument is the basis, and the second argument is the calculation, where the passed basis argument, is available as the size
keyword. This means we can rewrite our rule as height: calc-size(auto, size);
, and our transition should immediately work:
.disclosure-widget[open] {
height: calc-size(auto, size);
}
Passing the auto
keyword in calc-size()
enables the browser to animate the transition.
The calc-size()
is not supported in all browsers, so as a fallback, you can leave the height
declaration from the original example in the code—in which case, browsers that don’t support calc-size()
will ignore its declaration and fallback to height: auto;
:
.disclosure-widget[open] {
height: auto;
height: calc-size(auto, size);
}
The second, calculation argument lets you perform any calculations you can with calc()
, including calculations on intrinsic sizes: calc-size(auto, size + 50px)
.
CSS interpolate-size
property
The calc-size()
function does solve our transitioning problems with intrinsic sizes (including height: auto;
), but it still feels like a hack unless you’re trying to do an actual calculation.
That's precisely why we got the interpolate-size
property, which lets you choose the interpolation behavior and decide for yourself if you want the browser to interpolate intrinsic sizes.
The default value is numeric-only,
which is the behavior you’re familiar with, where only transitions and animations to numeric values (like 250px
) work. The new value you can use is allow-keywords
, which will, as it clearly states, let you interpolate between keyword values.
To enable this new animation behavior, specify the interpolate-size
on the :root
element to opt-in to the new behavior for the entire page:
:root {
interpolate-size: allow-keywords;
}
Of course, you can restrict it to elements that you want, but even W3C specification suggests you enable the new behavior for the entire page:
Specifying
interpolate-size: allow-keywords
on the root element chooses the new behavior for the entire page. We suggest doing this whenever compatibility isn’t an issue.
Once we set interpolate-size
to allow-keywords
in our demo, we can remove the calc-size()
function and only use the value auto
.
Specifying interpolate-size: allow-keywords;
on the :root
element enables the new animation behavior for the entire page.
It will work precisely as expected, exactly as it should have worked from the start when we got CSS Transitions. A similar stance is represented in W3C Editor’s Draft CSS Values and Units Module Level 5:
If we had a time machine, this property wouldn’t need to exist. It exists because many existing style sheets assume that intrinsic sizing keywords (such as
auto
,min-content
, etc.) cannot animate. Therefore this property exists to allow style sheets to choose to get the expected behavior.
Browser support for CSS interpolate-size
property and calc-size()
function
The calc-size()
function and interpolate-size
property are supported by default in Chrome 129.
Neither calc-size()
nor interpolate-size
works in Safari or Firefox, but there are open issues on GitHub for both Firefox and Safari, so hopefully, we'll see these features in Baseline soon.
Features status by vendor:
Blink - Shipping on desktop 128
WebKit - Unknown
Mozilla - Unknown
You can check if the browser supports calc-size()
and interpolate-size
using the @supports
at-rule:
/* Check calc-size() function support */
@supports (height: calc-size(auto, size)) {
/* ... */
}
/* Check interpolate-size property support */
@supports (interpolate-size: allow-keywords) {
/* ... */
}
Workaround with JavaScript
As neither calc-size()
nor interpolate-size
are part of Baseline, you will need to use a workaround to transition to intrinsic size if you want to see animations in production.
One reliable workaround is to calculate the element size in JavaScript and then set that exact number as the container height instead of the keyword auto
—in this case, CSS Transitions will work, as you’ll circumvent intrinsic sizes with numbers.
In CSS, you only need to set the widget height
and its transition
:
.disclosure-widget {
height: 0;
transition: all 0.7s ease-in-out;
}
In JavaScript, first, get the widget’s closed state height (because it could be different from 0
), then set the height
to auto
to force content-sized dimensions, get the element’s height and store it, and lastly, return the widget to its initial height:
// Get page elements
const toggle = document.querySelector('.toggle');
const widget = document.querySelector('.disclosure-widget');
// Widget State
let isOpen = false;
// Get the widget open/closed height
const closedHeight = widget.style.height;
widget.style.height = 'auto';
const openHeight = widget.offsetHeight + 'px';
widget.style.height = closedHeight;
// Handle widget state switch
toggle.addEventListener('click', (e) => {
let height = isOpen ? closedHeight : openHeight;
widget.toggleAttribute('open');
widget.style.height = height;
isOpen = !isOpen;
});
This JavaScript-based solution works in all modern browsers and doesn’t require extra markup in HTML.
The downsides of this approach are more complexity in JavaScript and potential performance penalties, as calculating the correct size of the element requires forcing extra layouts to happen.
Workaround with CSS Grid
There is also an alternative approach using CSS Grid and fraction units to get around this issue. As CSS Grid and its fr
units are animatable, you can set the closed state of grid-template-rows
to 0fr
and then transition the open state to 1fr
. As long as you have only one row in your grid, 1fr
will take the entirety of available space, which translates exactly to the auto
value.
This is how the setup could look:
.disclosure-widget {
display: grid;
grid-template-rows: 0fr;
overflow: hidden;
transition: all 0.7s ease-in-out;
}
.disclosure-widget__container {
min-height: 0;
}
.disclosure-widget[open] {
grid-template-rows: 1fr;
}
This CSS Grid-based solution works in all modern browsers.
The downsides of this approach are that you need an extra element in HTML as a container, and you’re forced to opt-in to CSS Grid even if you don’t really need it.
CSS Transition from display: none;
Another challenge related to transitioning from height: 0;
to height: auto;
is that often, in real-world scenarios, you’ll want this transition to happen at the moment the element receives its first style update—on page load, when it’s added to DOM, or when its display
property changes from none
.
Let’s update our calc-size()
example so that in the hidden state, the element is not only visually hidden but also hidden from screen readers using display: none;
.
.disclosure-widget {
display: none;
height: 0;
transition: all 1s ease-in-out;
}
.disclosure-widget[open] {
display: block;
height: auto;
height: calc-size(auto, size);
}
After adding display: none;
to the hidden state, the animation no longer works.
As you might have expected, the transition (animation) is no longer working because the display is not an animatable property, meaning it can’t gradually be flipped from none
to block
. When the transition occurs, the value changes immediately, and the element disappears without transition. Likewise, the element is not animated on animation-in because CSS Transitions are not triggered on an element's initial style update—when its display
changes from none
to another value.
Let’s work around both of these issues with new CSS additions, the @starting-style
at-rule, and the transition-behavior
property.
CSS @starting-style
at-rule
You can use @starting-style
at-rule to enable transitions when the display
value changes or when an element is first added to the page.
Within the @starting-style
block, you simply need to specify the rules from which you want the transition to start. This is necessary because the elements that are first-time added to the page don’t have a previous state, so there is nothing from which the browser can create a transition to the state you want.
For our example, the only value we want to transition is height
, so our @starting-style
will look like this:
.container[open] {
height: calc-size(auto, size);
display: block;
@starting-style {
height: 0;
}
}
With initial styles specified in @starting-style
at-rule, the browser can animate the transition on the first style update—when thedisplay
value changes fromnone
.
The @starting-style
at-rule can be used as a standalone rule or nested within a ruleset. In the previous example, we nested it directly within our selector. If you want to use it as a standalone rule, you need to specify the selector for which it should be applied:
@starting-style {
.container[open] {
height: 0;
}
}
The @starting-style
at-rule doesn’t increase the specificity—it has the same specificity as the original rule, so make sure you include it after your original rule to avoid the situation where your original rule overrides it.
CSS transition-behavior
property
The @starting-style
at-rule fixes our transition-in problem, but transition-out is still not working. As mentioned, display
isn’t an animatable property, so its value is immediately flipped from block
to none
when the transition starts—hiding the animation.
That’s where the transition-behavior
property comes in. It lets you specify if transitions should be started for properties that aren’t animatable; specifically for properties whose animation behavior is discrete, like display
and content-visibility
.
Possible values for transition-behavior
are normal
and allow-discrete
. The normal
value means that transitions won’t be started for discrete properties, and it’s the default behavior.
If we change the transition-behavior
in our example to allow-discrete
, our transition-out will work as expected:
.disclosure-widget {
display: none;
height: 0;
transition: all 0.7s ease-in-out;
transition-behavior: allow-discrete;
}
Specifiying transition-behavior: allow-discrete;
tells the browser to start transitions for non-animatable properties.
The transition-behavior: allow-discrete;
changes the behavior of the display
property by flipping its value at the end of the transition so that the animation has time to happen before the element ‘disappears.’
The thing to note is that you need to specify the transition on the discrete property as well. In our example, we’ve used the all
keyword, which includes all properties. However, if we switch this to only height
, transition-behavior
won’t have any effect because we’re not transitioning the display
property. Only when we specifically add it as a transition-property
will the transition-behavior
apply to it.
If we rewrite our transition
from the shorthand values, this is how it would look:
.disclosure-widget {
display: none;
height: 0;
transition-property: height, display;
transition-duration: 0.5s;
transition-timing-function: ease-in-out;
transition-behavior: allow-discrete;
}
You can use the @starting-style
at-rule and transition-behavior
property combo any time you want to apply a transition to elements that are injected into DOM or on page load and with all disclosure widgets (native or custom) like Dialog, Popover, and so on.
Browser support for CSS @starting-style
at-rule and transition-behavior
property
The @starting-style
at-rule is supported in stable versions of Chrome, Edge, Safari, and Firefox.
The transition-behavior
property is supported in stable versions of Chrome and Edge but not in Safari and Firefox. It should ship in Safari 18, but there is no indication when it will be available in Firefox.
There is a problem with the current version of Firefox, version 129. The @starting-style
at-rule doesn’t support animating from display: none;
, so in this case, the transition-in in our demo will not be animated in Firefox.
Still, you can use both new features immediately as progressive enhancement. In the browsers that support those properties, users will see nice, animated transitions, and in the browsers that don’t support them, disclosure widgets will be functional, just not animated.
You can check if the browser supports transition-behavior
using the @supports
at-rule:
/* Check transition-behavior property support */
@supports (transition-behavior: allow-discrete) {
/* ... */
}
Detecting @starting-style()
support is not that necessary as the feature is already in Baseline, and it’s a bit more complicated, as @supports
still can’t detect at-rules. Once the browsers implement at-rule support detection, you’ll be able to test it like this:
/* Check @starting-style() at-rule support */
@supports at-rule(@starting-style) {
/* ... */
}
Workaround with JavaScript
An alternative solution, if you don’t want to use transition-behavior
as progressive enhancement, is to switch the display value after the transition finishes using JavaScript.
Even though the @starting-style
at-rule is supported in all modern browsers, for simplicity and clarity, we’ll handle both cases manually:
// Get page elements
const toggle = document.querySelector('.toggle');
const widget = document.querySelector('.disclosure-widget');
// Widget state
let isOpen = false;
// Set display to "block" immediately after clicking
// the button before adding the "open" attribute
// to ensure the CSS transition happens
toggle.addEventListener('click', (e) => {
if (isOpen === false) {
widget.style.display = 'block';
}
// Ensure the display is switched before
// the "open" attribute is toggled
requestAnimationFrame(() => {
widget.toggleAttribute('open');
});
// Click always toggles the state
isOpen = !isOpen;
});
// At the end of the transition,
// when the widget is closed,
// hide it by changing the display value
widget.addEventListener('transitionend', (event) => {
// We only want to trigger this on the close transition
if (isOpen === false) {
widget.style.display = 'none';
}
});
On animation-in, we switch the display
property to block
and wait using requestAnimationFrame
until the next tick of the event loop to flip the open
attribute and trigger the CSS Transition.
On animation-out, we wait for the transition to end using the transitioned
event and only then switch the display
to none
to give the browser time to finish the transition before we hide the element.
Conclusion
Hopefully, this gives you an idea of how new CSS features can simplify your code by entirely removing Javascript requirements, allowing you to create smooth interactive widgets with just a few lines of CSS.
With the interpolate-size
property and calc-size()
function, you can animate transitions to intrinsic sizes (most notably to height: auto;
).
The transition-behavior
property and @starting-style
at-rule let you use transitions for elements that are added to the page or removed from the DOM and for elements that are hidden from screen readers using display: none;
or content-visibility: hidden;
.