← Writing
5 min read

Animating height, four ways

You cannot transition to height: auto. Or rather, you can now, but only in some browsers. The four real techniques, what each costs, and which one to reach for.

  • css
  • technique

Expanding an accordion, revealing a details panel, growing a container as content arrives: this is one of the most common animations in interface work, and for twenty years it was one of the most annoying to write.

The problem is simple to state. You know the collapsed height: zero. You do not know the expanded height, because it depends on the content. And height: auto is not an animatable value. The browser has no numeric target to interpolate toward, so the transition does nothing and the element snaps.

There are four ways around this. They are not equivalent.

1. interpolate-size: the real fix

Modern CSS can now do this directly:

:root {
  interpolate-size: allow-keywords;
}

.panel {
  height: 0;
  overflow: hidden;
  transition: height 250ms cubic-bezier(0.33, 1, 0.68, 1);
}

.panel[data-open] {
  height: auto;
}

That single interpolate-size declaration opts the page into animating to and from intrinsic sizes: auto, min-content and fit-content. It is what everyone wanted all along.

There is also calc-size(), which does the same job at the value level rather than as a page-wide opt-in:

.panel { height: 0; transition: height 250ms; }
.panel[data-open] { height: calc-size(auto, size); }

The catch is support. This is genuinely new, and it is not everywhere yet. Used naively, non-supporting browsers get a hard snap instead of an animation. That is a perfectly acceptable degradation for most products, but it is a decision you should make deliberately rather than discover in a bug report.

If you want to be careful, feature-detect and let the fallback be an instant open:

@supports (interpolate-size: allow-keywords) {
  :root { interpolate-size: allow-keywords; }
}

2. grid-template-rows: 0fr → 1fr: the best-supported technique

This is the one to reach for when you cannot rely on interpolate-size. It is widely supported, requires no JavaScript, and handles unknown content height correctly.

.wrapper {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 250ms cubic-bezier(0.33, 1, 0.68, 1);
}

.wrapper[data-open] {
  grid-template-rows: 1fr;
}

.wrapper > .content {
  overflow: hidden;   /* required: without it the content spills while collapsed */
  min-height: 0;      /* required: grid items default to min-height: auto */
}

The trick is that fr units are animatable, and a single-row grid track sized 1fr resolves to exactly the content’s height. So you get height-of-auto behaviour with an interpolatable property.

Two gotchas, both easy to hit:

  • min-height: 0 on the child is mandatory. Grid items have an automatic minimum size that refuses to shrink below their content, which silently defeats the whole thing.
  • The overflow: hidden goes on the child, not the grid wrapper.

The markup cost is one extra wrapper element. That is the whole downside, and it is why this technique is the pragmatic default.

3. Measure with JavaScript: the escape hatch

Read the natural height, set it explicitly, animate to it, then hand control back:

function expand(el) {
  el.style.height = '0px';
  const target = el.scrollHeight;
  el.style.height = target + 'px';

  el.addEventListener('transitionend', function done() {
    // Release to auto so later content changes reflow naturally.
    el.style.height = 'auto';
    el.removeEventListener('transitionend', done);
  });
}

This works everywhere and has always worked. It also has real costs.

scrollHeight forces a synchronous layout. The browser must stop and compute geometry mid-frame. Once is fine. Doing it for twenty list items in a loop is a measurable jank source.

It is also fragile under interruption. If the user collapses the panel mid-expansion, transitionend may never fire, and the element is stranded at a fixed pixel height that no longer matches its content. Every robust implementation of this ends up managing that state, which is how a six-line function becomes forty.

Use it when you need something the CSS techniques cannot express, such as animating to a specific computed height, or coordinating with measurements you already took.

4. max-height: the one to stop using

The old standby:

.panel { max-height: 0; overflow: hidden; transition: max-height 300ms ease; }
.panel[data-open] { max-height: 500px; }  /* a guess */

It is popular because it is one line and needs no wrapper. It is also wrong in ways that are worth spelling out, because it is still widely copied.

The magic number is a bug waiting. Guess too low and content clips, silently, and only for the users whose content happens to be long. Guess too high and the animation appears to stall: if real content is 200px and max-height is 800px, three-quarters of your duration animates through empty space where nothing visibly moves. The panel opens fast and then seems to hang.

The timing is uncontrollable. Perceived duration varies with content length, so the same accordion feels different row to row.

The only place it still makes sense is a genuinely fixed-size element, and at that point you know the height and can animate height directly anyway.

Whichever you pick: animate the container, fade the contents

A detail that separates a good expansion from a serviceable one. Animating only the container means content is fully opaque at 1px tall, hard-clipped by overflow: hidden. It reads as a mask sliding, not a thing opening.

Fading the contents over a shorter, slightly delayed window fixes it:

.content {
  opacity: 0;
  transition: opacity 150ms ease-out;
}
.wrapper[data-open] .content {
  opacity: 1;
  transition-delay: 100ms;
}

Container expands over 250ms; contents fade in over 150ms starting at 100ms. The reveal finishes with the expansion instead of preceding it. This is follow-through: parts arriving at slightly different times read as weight.

On collapse, reverse the relationship: fade the content out first, quickly, then collapse. Content that is still visible while its container shrinks is what produces the crushed look.

Which to use

Situation Technique
You control the browser targets interpolate-size: allow-keywords
General production work grid-template-rows: 0fr → 1fr
Need a specific measured height, or coordinating with existing measurements JavaScript measurement
Element has a genuinely fixed height plain height
Anything not max-height

For duration, expansions sit in the 200–350ms band depending on how far the panel travels (see how long a UI animation should take). Ease-out on the way open, slightly faster on the way closed.

One last note that applies to all four: height and grid-template-rows are layout properties, so they trigger reflow every frame. That is unavoidable here, and fine for one or two elements. If you are expanding a dozen simultaneously, you will feel it, which is a good reason not to.