Discrete & Intrinsic-Size Animation
Part of Modern View Transitions & Scroll APIs.
For most of CSS’s history two kinds of change could not be animated at all. A property with no in-between values — display, content-visibility, overlay — could only snap. And a size expressed as a keyword — height: auto, width: fit-content — could not be interpolated because the browser had no number to interpolate from. Every accordion, every collapsing panel and every “show more” button worked around both gaps with JavaScript measurement, max-height guesses or wrapper elements.
Three newer features close those gaps. transition-behavior: allow-discrete lets a discrete property take part in a transition, flipping at a well-defined moment instead of immediately. interpolate-size: allow-keywords and the calc-size() function let a size keyword resolve to a concrete length that can be interpolated. And the ::details-content pseudo-element exposes the collapsible part of <details> so it can be sized and transitioned like any other box. This topic covers how they behave in the rendering pipeline, where they fit alongside @starting-style entry effects, and why the size half of the story is always a layout animation you have to budget for.
Concept definition
A discrete animation is one where the property’s value cannot be blended — there is no halfway between block and none. By default such properties are excluded from transitions entirely, so a change applies the instant the style changes. With allow-discrete, the property is still discrete, but the flip is scheduled inside the transition: for most properties at 50% of the duration, and for display and content-visibility at whichever end keeps the element visible for the whole animation.
An intrinsic-size animation is one where one end of the transition is a content-derived size keyword. Without help, height: 0 to height: auto is also discrete, which is why it snaps. interpolate-size: allow-keywords tells the browser to resolve the keyword to the length it would compute to and interpolate between lengths. calc-size(auto, size) does the same thing explicitly for one declaration and additionally lets you do arithmetic on the resolved size.
The two features are usually used together. A panel that collapses to display: none needs allow-discrete so the display flip waits until the height reaches zero, and it needs keyword interpolation so the height has somewhere to go.
Execution model
Discrete flips and intrinsic sizes run through different parts of the frame, and the difference decides what they cost.
The discrete flip is resolved during style. When a transition that includes a discrete property starts, the browser creates a transition whose value holds the old keyword until the switch point and the new keyword afterwards. No interpolation happens; the only per-frame work is checking which side of the switch point the current time is on. The expensive part is the moment of the flip itself: switching display from none to block creates boxes, runs layout for the subtree and paints it, all in one frame. That cost is identical to a non-animated display change — allow-discrete moves it in time, it does not remove it.
The switch point is chosen so the element stays rendered for the duration of the animation. When display goes from none to something visible, the flip happens at 0%, so the element exists for every frame of its entrance. When it goes to none, the flip happens at 100%, so the exit is visible until the end. Every other discrete property flips at the midpoint.
The intrinsic-size interpolation is resolved in layout. At the start of the transition the browser computes what auto would be — which means laying out the content at its natural size — and then interpolates a length toward it. Every frame of the transition changes the element’s used height, and a height change invalidates layout for the element, its following siblings and, unless something contains it, its ancestors. Every frame then repaints whatever moved. This is the opposite end of the spectrum from compositor-only properties: it is a main-thread layout animation by definition.
What that means in practice is that the new primitives are a correctness improvement, not a performance one. They let you express an expand or collapse without script and without a max-height guess that produces the wrong easing, but the frames are still layout frames. The work stays affordable when the animating box is small and its layout is contained; it becomes a problem when a tall panel near the top of a long document pushes everything below it down on every frame.
Property / API reference table
| Property / API | Accepted values | Compositing tier | Notes |
|---|---|---|---|
transition-behavior |
normal, allow-discrete |
style | Can be set per property in the transition shorthand |
display with allow-discrete |
any keyword | layout at the flip | Flips at 0% entering, 100% leaving |
content-visibility with allow-discrete |
visible, auto, hidden |
layout at the flip | Same visibility-preserving switch point as display |
overlay |
none, auto |
style | Keeps a top-layer element in the top layer during its exit |
interpolate-size |
numeric-only, allow-keywords |
layout per frame | Inherited; set once on :root |
calc-size() |
calc-size(auto, size) and arithmetic on size |
layout per frame | Per-declaration opt-in; allows size + 2rem, size * 0.5 |
::details-content |
any box properties | layout per frame | The slot holding everything after <summary> |
@starting-style |
nested rules | style | Supplies the from-value when an element first renders |
grid-template-rows |
0fr to 1fr |
layout per frame | The older height-auto technique; interpolates without opt-in |
The last row is worth keeping in view. fr values in grid-template-rows have interpolated for years, and the 0fr to 1fr trick remains the widely supported fallback for browsers that do not yet understand interpolate-size. It is exactly as much of a layout animation as the new syntax; it just needs an extra wrapper.
Annotated code examples
1 — A collapsible panel that animates to and from auto
/* Intent: expand and collapse a filter panel with no JavaScript measurement. */
:root {
interpolate-size: allow-keywords; /* inherited: every descendant can interpolate keywords */
}
.filters {
contain: layout paint; /* keep each frame's layout inside the panel */
overflow: clip; /* hide content while the box is shorter than it */
block-size: auto;
transition:
block-size 280ms cubic-bezier(0.2, 0.8, 0.2, 1),
display 280ms allow-discrete; /* flip to none only when the height is 0 */
}
.filters[data-state="closed"] {
block-size: 0;
display: none;
}
@starting-style {
.filters[data-state="open"] { block-size: 0; } /* from-value when leaving display: none */
}
@media (prefers-reduced-motion: reduce) {
.filters { transition: display 0s allow-discrete; } /* instant show and hide */
}
Rendering Impact: layout. Every frame changes the panel’s used block size, so layout runs for the panel and whatever it pushes.
contain: layout paintstops the invalidation reaching ancestors; it cannot stop following siblings moving if they are in the same flow.
The @starting-style block is the part most people miss. When the panel reopens it is leaving display: none, which means it had no computed style on the previous frame. Without a starting style there is no from-value, and the height snaps to auto even though the transition is declared.
2 — A tooltip that fades out before leaving the top layer
/* Intent: a popover that fades on close instead of vanishing on the first frame. */
[popover].tip {
opacity: 1;
transform: translateY(0);
transition:
opacity 160ms ease-out,
transform 160ms ease-out,
overlay 160ms allow-discrete, /* stay in the top layer until the fade ends */
display 160ms allow-discrete;
}
[popover].tip:not(:popover-open) {
opacity: 0;
transform: translateY(4px);
}
@starting-style {
[popover].tip:popover-open { opacity: 0; transform: translateY(4px); }
}
@media (prefers-reduced-motion: reduce) {
[popover].tip { transition: overlay 0s allow-discrete, display 0s allow-discrete; }
}
Rendering Impact: composite during the fade.
opacityandtransforminterpolate on the compositor;overlayanddisplayare discrete and cost one style recalculation at the 100% flip.
Without overlay in the transition list, the popover drops out of the top layer on the first frame of its exit and renders underneath whatever it was covering while it fades — the classic “tooltip disappears behind the card” bug. The complete popover and dialog pattern lives in animating popover and dialog entry and exit.
3 — Sizing with arithmetic using calc-size()
/* Intent: open a "read more" excerpt to its full height plus room for a fade-out mask. */
.excerpt {
overflow: clip;
block-size: 6lh; /* collapsed: six lines */
transition: block-size 320ms cubic-bezier(0.3, 0.7, 0.2, 1);
}
.excerpt[data-expanded] {
block-size: calc-size(auto, size + 1.5rem); /* natural height plus padding for the mask */
}
@media (prefers-reduced-motion: reduce) {
.excerpt { transition: none; }
}
Rendering Impact: layout per frame, bounded by the excerpt.
calc-size()opts in for this declaration only, so the rest of the document keeps the defaultnumeric-onlybehaviour.
calc-size() is the better tool when you need the resolved size inside arithmetic, or when you cannot set interpolate-size globally — for example inside a third-party widget whose styles you do not control.
DevTools workflow
- Confirm the transition exists. Open the Elements panel, select the element, trigger the change and check the Animations drawer (More tools → Animations). A size transition appears as a
CSSTransitiononblock-sizeorheight. If onlyopacityappears, keyword interpolation is not enabled for that element — check thatinterpolate-sizeis inherited or thatcalc-size()is used. - Check the discrete flip timing. In the Animations drawer, scrub the timeline to the last frame. The element should still be present in the Elements tree with
display: blockuntil the scrubber reaches the end. If it disappears at the start,allow-discreteis missing from thedisplayentry. - Measure the layout cost. Record in the Performance panel while expanding. Each frame should show a purple Layout event; select one and read “Nodes that need layout” and the layout root in the Summary tab. A layout root at the document means containment is not doing its job.
- Watch what moves. Enable Rendering → Layout Shift Regions. Content below the panel will flash on every frame; that is expected for an in-flow panel. If content above or beside it flashes, something is sized relative to the panel and should be decoupled.
- Verify the reduced-motion branch. Rendering → Emulate CSS media feature prefers-reduced-motion: reduce, repeat the toggle, and confirm the Animations drawer records nothing for the size property.
Failure modes & fixes
- The panel opens smoothly but closes instantly. Root cause:
display: noneis in the closed state but not in the transition list, so it applies on the first frame and removes the box. Fix: adddisplay 280ms allow-discretealongside the size transition. - The panel closes smoothly but opens instantly. Root cause: the element is leaving
display: none, so it has no previous style to interpolate from. Fix: add an@starting-stylerule that sets the collapsed size for the open state. - The easing looks wrong with a
max-heightworkaround. Root cause:max-height: 1000pxinterpolates toward 1000px, not toward the content height, so a 200px panel finishes its visible motion in the first fifth of the duration and the rest is dead time. Fix: switch tointerpolate-sizeor thegrid-template-rowstechnique, both of which target the real size. - Everything below the panel judders on low-end devices. Root cause: per-frame layout of a long following document. Fix: contain the panel, move it lower in the flow, or animate a
transform: scaleY()reveal on an absolutely positioned overlay instead and let the document jump once. - A popover fades out behind other content. Root cause: the element left the top layer on the first frame of its exit. Fix: include
overlaywithallow-discretein the transition list.
Accessibility and reduced-motion notes
Expanding and collapsing content moves everything beneath it, which is a larger visual displacement than the panel’s own size suggests. For users who have asked for reduced motion, remove the size transition and keep the state change instant; the content still appears and disappears, so nothing functional is lost. The two-layer reduced-motion cascade handles this cleanly if the transition durations come from tokens.
Keep the discrete properties honest about state. An element that is visually collapsed but still display: block for 300ms is reachable by keyboard and exposed to assistive technology during that window, which is fine for an exit but confusing if the collapsed state persists. The allow-discrete switch point guarantees display: none lands at the end, so the element leaves the accessibility tree exactly when it finishes leaving the screen.
For disclosure widgets, prefer the native <details> element and animate ::details-content rather than rebuilding the widget from a <div>: the expanded state, keyboard toggle and accessible name come for free, and the animation is purely presentational. The pattern is covered step by step in animating details and summary with ::details-content.
Choosing between the size techniques
Three techniques now reach the same visual result, and the choice comes down to support and structure rather than performance — they are all layout animations.
interpolate-size: allow-keywords is the cleanest: one declaration on :root, no markup changes, and every auto in the document becomes animatable. Its limit is browser support; where it is not understood the transition silently snaps, which is an acceptable progressive enhancement for most collapses.
calc-size() is the scoped version, useful for arithmetic or for components that must not change document-wide behaviour. Because an unsupported calc-size() makes the whole declaration invalid, pair it with a plain block-size: auto fallback declared first.
grid-template-rows: 0fr to 1fr works nearly everywhere, at the cost of a grid wrapper and a child with min-block-size: 0. It is the right default when the collapse is essential to the design and must animate in every supported browser. The detailed comparison, including the scaleY() alternative that avoids layout entirely, is in expand and collapse techniques compared.
Frequently asked questions
Is animating height: auto still bad for performance?
It is still a layout animation, so it costs main-thread time on every frame. The new syntax removes the need for measurement scripts and max-height guesses, but it does not move the work to the compositor. Keep the animating box small, contain its layout and keep durations short.
Why does my display transition do nothing without allow-discrete?
Discrete properties are excluded from transitions by default, so display changes on the first frame regardless of the transition declaration. transition-behavior: allow-discrete opts the property in and schedules the flip at the end that keeps the element visible.
Should I set interpolate-size on :root or on individual components?
On :root, in most cases. It is inherited and only affects transitions that involve size keywords, so enabling it globally rarely changes anything unintended. Use calc-size() per declaration when you need arithmetic or cannot change global styles.
Does allow-discrete work with CSS keyframe animations?
Keyframe animations have always been able to set discrete properties at keyframes, flipping at the midpoint between them. allow-discrete is about transitions; for keyframes, place the display value in the keyframe where you want the change to happen.
What happens in browsers that do not support interpolate-size?
The declaration is ignored and the size change snaps, while any opacity or transform transitions on the same element still run. That makes it a safe progressive enhancement for most collapses.
Related
- CSS @starting-style & Entry Effects — the from-value every element leaving display: none needs
- Animating height: auto with interpolate-size and calc-size() — the size opt-ins in detail
- Animating details and summary with ::details-content — a native accordion that opens smoothly
- Expand and Collapse Techniques Compared — interpolate-size, grid rows and scaleY side by side
- Avoiding Layout Thrashing in CSS Animations — why per-frame layout needs a budget
- Animating display in Keyframe Animations — use display inside @keyframes to show and hide elements as part of an
- Animating width: auto for Buttons and Labels — smoothly resize buttons whose labels change — Save to Saving… to Saved — and
- Transitioning visibility and content-visibility — how visibility’s special interpolation keeps elements visible through fades,