Transition to `height: auto` & `display: none` Using Pure CSS

Zoran JamborZoran Jambor
Aug 09, 2024·
14 min read

💡
UPDATE, September 12, 2024: In the earlier version of this article, 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.

💡
Note: The features mentioned in this article are experimental, subject to change, and not yet ready for production. However, you can test them and use them as progressive enhancements today.

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.

💡
Note: The 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 to calc(), but that also supports operations on exactly one of the values auto, min-content, max-content, fit-content, stretch, or contain, 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 the calc-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 thedisplayvalue 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;.

Further Reading

33
Subscribe to my newsletter

Read articles from Zoran Jambor directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Zoran Jambor
Zoran Jambor

Frontend developer & content creator, author of CSS Weekly.