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.
Step-by-step resolution
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
.detailsand lays out its following siblings.contain: layoutkeeps 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.
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-sizesnap. That is usually acceptable progressive enhancement; if motion is essential, use thegrid-template-rowstechnique 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-sizeon:rootalso enables keyword interpolation inside third-party components; test widgets that transitionwidthorheightafter 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.
Related
- Discrete & Intrinsic-Size Animation — the parent topic on allow-discrete and keyword sizing
- Expand and Collapse Techniques Compared — when grid rows or scaleY are the better choice
- Using @starting-style for Modal Entry Effects — the same from-value problem for dialogs