Expand and Collapse Techniques Compared

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

The problem

There are now at least five ways to make a region grow from nothing to its natural size, and codebases tend to contain several of them at once: a max-height hack from years ago, a script that measures scrollHeight, a grid-rows trick someone copied from a blog, and newer components using interpolate-size. Each looks similar in a demo and behaves differently under real conditions — long content, slow devices, changing text, reduced motion.

Choosing well means asking a question most comparisons skip: does the content below the region need to move smoothly, or only the region itself?

Root cause: two different jobs share one name

“Expand” describes two effects that the rendering pipeline treats completely differently.

In a reflowing expand, the region is in normal flow and everything after it must move down as it grows. The size of a box in flow is decided by layout, so every intermediate frame requires layout for the region and its following content. There is no compositor-only way to do this: interpolate-size, grid-template-rows, max-height and measured heights are all variations on animating a layout property.

In an overlay reveal, the region is out of flow — a dropdown, a menu, a toast — or it is acceptable for surrounding content to jump once. Nothing else needs to move per frame, so the effect can be a transform: scaleY() or clip-path animation, which runs on the compositor and costs no layout at all.

Most expand/collapse performance problems come from using a reflowing technique for something that only needed a reveal, or from forcing a reveal technique to fake reflow with counter-scaled children.

Five techniques on the criteria that matterOnly scaleY avoids per-frame layout, and it cannot move surrounding content.Five techniques on the criteria that matterMoves content belowPer-frame layoutAccurate easingBrowser supportinterpolate-sizeYesYesYesNewest enginesgrid rows 0frto 1frYesYesYesBroadmax-heightceilingYesYesNoUniversalJS measuredheightYesYes + forcedYesUniversaltransform:scaleYNoNoneYesUniversal
Only scaleY avoids per-frame layout, and it cannot move surrounding content.

Decision matrix

If… Then use Because
The region overlays the page (menu, dropdown, toast) transform: scaleY() or clip-path: inset() No layout; runs on the compositor
Content below must move and motion is a nice-to-have interpolate-size: allow-keywords No wrapper, no script; snaps gracefully where unsupported
Content below must move and motion must work everywhere grid-template-rows: 0fr to 1fr Interpolates in all current engines
It is a native disclosure ::details-content with keyword sizing Keeps native semantics — see animating details with ::details-content
You are maintaining an existing max-height hack Replace it with grid rows The easing is wrong for every content height but one
You need the height in script anyway Measured height via the Web Animations API At least reads once, before the animation
Scripting plus layout per frame, expanding a 240px region above 40 list itemsMid-range phone, 4x CPU throttle. scaleY does not move the list, so it is not doing the same job.Scripting plus layout per frame, expanding a 240px region above 40 list itemsbudget 16.7 mstransform: scaleY (overlay)0.4 msinterpolate-size6.2 msgrid rows 0fr to 1fr6.9 msmax-height: 1000px6.4 msJS measured, read per frame18.3 ms
Mid-range phone, 4x CPU throttle. scaleY does not move the list, so it is not doing the same job.

The three CSS layout techniques cost roughly the same per frame — they all lay out the same boxes. The meaningful differences are elsewhere: support, markup, and whether the easing is honest. The one outlier is a script that re-reads layout on every frame, which forces a synchronous layout on top of the one the frame already needed. That pattern is described in avoiding layout thrashing.

Production code pattern

/* Reflowing expand with the widest support: grid rows. */
.collapsible {
  display: grid;
  grid-template-rows: 0fr;                           /* collapsed: the row has no size */
  transition: grid-template-rows 260ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.collapsible.is-open {
  grid-template-rows: 1fr;                           /* open: the row takes its content height */
}
.collapsible > .collapsible__inner {
  min-block-size: 0;                                 /* lets the row shrink below content size */
  overflow: clip;
}

/* Overlay reveal: compositor-only, does not move the page. */
.menu {
  position: absolute;
  transform-origin: top;
  transform: scaleY(0.6);
  opacity: 0;
  transition: transform 180ms ease-out, opacity 180ms ease-out;
}
.menu.is-open {
  transform: scaleY(1);
  opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
  .collapsible, .menu { transition: none; }
  .menu { transform: none; }                         /* no stretch effect at all */
}

Rendering Impact: layout for .collapsible, composite for .menu. The grid version lays out the region and the content after it on every frame; the menu promotes to its own layer and only its transform and opacity change.

Two details make the grid version work. The single child must be allowed to shrink below its content size, which min-block-size: 0 provides; without it, the 0fr row is floored at the content height and nothing collapses. And the clipping must be on the child, not the grid, so that padding on the grid container does not remain visible when the row is at zero.

The scaleY() menu uses opacity to hide the distortion at the start of the animation. A scale reveal squashes text, which is fine for 180ms and a short distance but obvious on tall content — another reason to keep it for small overlays.

What a user sees halfway through each approachBoth are correct for their job; swapping them produces either jank or a squashed page.What a user sees halfway through each approachReflowing expand (grid rows)Region is half its final heightText is full size, lower lines clippedList below has moved half the distanceLayout every frameOverlay reveal (scaleY)Region is drawn at 80% vertical scaleText is briefly squashedNothing else on the page has movedComposite only
Both are correct for their job; swapping them produces either jank or a squashed page.

Verification checklist

Constraints and trade-offs

  • Grid rows need a wrapper and a single child; the technique does not work on an element with multiple direct children unless they are wrapped.
  • interpolate-size is the least markup but the newest; treat the animation as an enhancement where it is not supported.
  • scaleY() distorts text and borders during the animation; counter-scaling children to hide that adds per-frame work and rarely looks right.
  • Any reflowing technique on a region above a long document is expensive regardless of syntax — the cheapest fix is often making the expand shorter or instant.
  • Measured heights in script are the only option when the target size depends on data you have in JavaScript before the DOM updates.

Frequently asked questions

Is the grid-template-rows technique faster than interpolate-size?

No. Both lay out the same content on every frame and cost about the same. Grid rows has broader support; interpolate-size needs no wrapper element. Choose on support and markup, not speed.

Why is max-height considered a bad technique?

Because it animates toward a ceiling rather than the real height. The visible growth finishes when max-height passes the content height, so a short panel completes its motion early and then pauses, and the closing animation begins with a delay. The easing curve is effectively distorted differently for every content length.

Can I get a reflowing expand without layout cost?

Not truly. Content moving in normal flow is layout by definition. You can make it cheaper by containing the region, keeping the duration short and avoiding expands above long content, or you can let surrounding content jump once and animate an overlay reveal instead.

Should the collapsed region stay in the DOM?

It can, but it must be removed from the tab order and the accessibility tree. The inert attribute, hidden, or display: none with allow-discrete all do that; clipping to zero height alone does not.