Animating height: auto with interpolate-size and calc-size()

Part of Discrete & Intrinsic-Size Animation in Modern View Transitions & Scroll APIs.

The problem

A card has a “show details” button. The details should slide open to whatever height their content needs and slide closed again. The obvious CSS — height: 0 in one state, height: auto in the other, a transition on height — does nothing: the box jumps between sizes on a single frame.

The traditional fixes each carry a defect. A JavaScript measure-and-set routine reads scrollHeight, writes it as an inline pixel height, waits for the transition, then removes it — three style writes and a forced layout per toggle, plus a stale height if content changes mid-animation. A max-height workaround animates toward an arbitrary ceiling, so the easing curve is spread over a distance the content never reaches and the visible motion finishes early.

Root cause: auto is not a number

Transitions interpolate computed values. 0px is a length; auto is a keyword whose meaning — “whatever the content needs” — is only resolved during layout. Between a length and a keyword there is no computed midpoint, so the property is treated as discrete and changes at once.

interpolate-size: allow-keywords changes that rule. When it applies, the browser resolves the size keyword to the length it would lay out at and interpolates between that length and the other end. The keywords covered are the intrinsic sizing ones: auto, min-content, max-content, fit-content and content. The property is inherited, so one declaration on :root enables the behaviour for the whole document.

calc-size() is the explicit, local form. calc-size(auto, size) means “the size auto resolves to”, and because it is a function it can carry arithmetic: calc-size(auto, size + 2rem), calc-size(fit-content, size * 0.5). A declaration that uses it interpolates whether or not interpolate-size is set.

Visible progress: max-height ceiling against a real targetA 180px panel animated toward max-height: 900px reaches full size a fifth of the way in; interpolate-size spends the whole curve on the real distance.Visible progress: max-height ceiling against a real target1.000.750.500.250.000.000.250.500.751.00elapsed time (fraction of duration)progressinterpolate-size, ease-out (0.2, 0.8, 0.2, 1)max-height: 900px, same easing
A 180px panel animated toward max-height: 900px reaches full size a fifth of the way in; interpolate-size spends the whole curve on the real distance.

Step-by-step resolution

From measure-and-set to declarative sizingEach step removes one piece of the old workaround.From measure-and-set to declarative sizing1Delete the script that reads scrollHeight and writes a pixel height.No forced layout on toggle2Add interpolate-size: allow-keywords to :root.Every auto in the document becomes interpolable3Transition block-size directly, not max-height.Easing is spent on the real distance4If the collapsed state uses display: none, add @starting-style for the open state.Reopening has a from-value5Set overflow: clip and contain: layout on the animating box.Content is hidden while short; layout stays local6Remove the size transition under prefers-reduced-motion: reduce.
Each step removes one piece of the old workaround.

The fourth step deserves a closer look because it produces the most common bug report: “it closes smoothly but opens instantly”. Closing starts from a rendered element with a real height, so there is a from-value. Opening from display: none starts from an element with no previous computed style at all. @starting-style provides the style the browser should pretend the element had on the frame before it appeared.

If the collapsed state keeps the element rendered — block-size: 0 with display left alone — you do not need @starting-style, but the collapsed content stays in the accessibility tree and in the tab order. Hide it with display: none via allow-discrete, or with the inert attribute, so keyboard users do not tab into invisible links.

Production code pattern

/* Global opt-in: size keywords interpolate everywhere. Inherited, so set once. */
:root {
  interpolate-size: allow-keywords;
}

.details {
  overflow: clip;               /* hide content while the box is shorter than it */
  contain: layout;              /* keep per-frame layout inside the box */
  block-size: auto;             /* open: the natural content height */
  transition:
    block-size 260ms cubic-bezier(0.2, 0.8, 0.2, 1),
    display 260ms allow-discrete;   /* none lands on the final frame of the close */
}

.card:not(.is-open) .details {
  block-size: 0;
  display: none;                /* removed from tab order and accessibility tree */
}

/* Reopening leaves display: none, which has no previous style. Give it one. */
@starting-style {
  .card.is-open .details { block-size: 0; }
}

/* Scoped alternative when you cannot touch :root, or need arithmetic. */
.excerpt[data-expanded] {
  block-size: auto;                          /* fallback: snaps where calc-size() is unknown */
  block-size: calc-size(auto, size + 1rem);  /* natural height plus room for a fade mask */
}

@media (prefers-reduced-motion: reduce) {
  .details { transition: display 0s allow-discrete; }  /* instant open and close */
  .excerpt { transition: none; }
}
// The only script left: flip the state. No measuring, no inline heights.
button.addEventListener('click', () => {
  const open = card.classList.toggle('is-open');
  button.setAttribute('aria-expanded', String(open));
});

Rendering Impact: layout. Each frame resolves a new block size for .details and lays out its following siblings. contain: layout keeps the invalidation from climbing to ancestors, but in-flow content below still moves every frame.

The duplicated block-size declaration on .excerpt is deliberate. An unsupported function makes its whole declaration invalid at parse time, so the plain auto above it remains in effect and the excerpt still expands — it just does so without animating.

Main-thread work per toggle, by techniqueTotal scripting and forced layout outside the animation frames themselves; measured on a mid-range phone.Main-thread work per toggle, by techniqueJS measure and set, with cleanup7.8 msmax-height ceiling0.6 msinterpolate-size0.5 mscalc-size() per declaration0.5 ms
Total scripting and forced layout outside the animation frames themselves; measured on a mid-range phone.

Verification checklist

Constraints and trade-offs

  • This is a layout animation. On a long page, a panel near the top shifts everything beneath it on every frame; keep it short in duration or move the panel.
  • Browsers without interpolate-size snap. That is usually acceptable progressive enhancement; if motion is essential, use the grid-template-rows technique instead.
  • If content changes while the transition runs — an image loads, text wraps differently — the resolved target changes and the animation retargets mid-flight, which can look like a stutter.
  • interpolate-size on :root also enables keyword interpolation inside third-party components; test widgets that transition width or height after enabling it.
  • Percent heights inside the animating box resolve against a changing size every frame, which multiplies layout work.

Frequently asked questions

Does interpolate-size affect elements that do not transition sizes?

No. It only changes how a transition or animation that involves a size keyword is interpolated. Static layout, and transitions between two lengths, behave exactly as before.

Can I animate width: auto the same way?

Yes. The opt-in covers any sizing property that accepts the intrinsic keywords, including width, inline-size, min and max sizes and flex-basis. Width changes usually cause more reflow than height changes because text rewraps, so budget accordingly.

Why use calc-size() if interpolate-size already works globally?

Two reasons: arithmetic, such as adding padding to the resolved size, and scope, when you cannot or should not change behaviour for the entire document. calc-size() opts in one declaration at a time.

Do I still need @starting-style if I never use display: none?

No. @starting-style is only needed when the element has no previous computed style, which is the case when it is created or leaves display: none. A box that stays rendered at block-size: 0 already has a from-value.