The problem

It would be nice if CSS could transition the size of elements between closed (zero) and their natural size. Or have parent elements transition their size based on which child is visible. While there are already some advances in getting browsers ready to handle this (see the interpolate-size question), it is not fully supported, and it also does not work in a generic way for all transitions.

There are a few approaches that are commonly used now, each with tradeoffs:

Common approaches

Per-frame JavaScript

Drawbacks:

  • Sixty layout recalculations per second, or capped to less calculations, even worse. Stutter time. A nightmare on mobile. This is still used out there, probably because of standard tooling, Angular shizzle, whatever.

The max-height hack

Pick a number (for example 2000px) that’s at least bigger than your content. The browser will transition the max-height. This is a pure CSS solution with no JavaScript.

CSS
.panel {
	max-height: 0;
	overflow: hidden;
	transition: max-height 0.3s ease-in-out;
}
.panel.open {
	max-height: 2000px; /* I guess this will be larger than our content */
}

Drawbacks:

  • Broken easing: the timing is counting towards the maximum size, not the actual content size. In the above example, the user will not see the '-out' part of the easing curve at all because the content has already stopped the growing.

The CSS Grid workaround

Transition grid-template-rows between 0fr and 1fr. The inner element uses overflow: hidden to clip the collapsed state. This is quite a smart trick.

CSS
.panel-outer {
	display: grid;
	grid-template-rows: 0fr; /* collapsed */
	transition: grid-template-rows 0.25s ease;
}
.panel-outer.open {
	grid-template-rows: 1fr; /* expanded */
}
.panel-outer > * {
	overflow: hidden;
}

Used by: Material Design, many modern CSS-only implementations.

Drawbacks:

  • It only works from 0 to something for a single panel. No cross transitioning between panels.
  • The element needs to be assigned as a grid, even though it may not be a real grid. Grid is just used here because it accidentally results in the behaviour we are pursuing, not because the content calls for a grid layout. It’s a CSS hack.

The lock-measure-animate-unlock (LMAU) cycle

Panel and PanelSet seem to be able to transition to or from a natural size, but they just use measurement to get explicit values. ‘Explicit’ sounds fancy, but it just means using real or relative values instead of auto. It is the same basic approach as used in for example react-collapse, but extended here to horizontal axes and panel switching. The sequence is the same for both:

  1. Lock: write the current dimension as an inline style. This gives CSS a concrete start value.
  2. Measure: make the target content briefly visible but absolutely positioned (so it does not affect flow), then read its offset dimension (offsetHeight or offsetWidth). This value is used in the next step.
  3. Animate: set the container’s inline style to the measured value inside a requestAnimationFrame call. The CSS transition runs between the locked start and the measured end.
  4. Unlock: once the transition ends, remove the inline style. The container is responsive again.
JavaScript
// Simplified.

// 1. Lock
container.style.height = container.offsetHeight + 'px';

// 2. Measure target (absolutely positioned, invisible)
nextPanel.style.position = 'absolute';
nextPanel.hidden = false;
const targetHeight = nextPanel.offsetHeight;

// 3. Animate
requestAnimationFrame(() => {
	container.style.height = targetHeight + 'px';
});

// 4. Unlock after transition
container.addEventListener('transitionend', () => {
	container.style.height = '';
	nextPanel.style.position = '';
}, { once: true });

What this enables

Expand and collapse

Any element from 0 (height or width) to its natural size, or back. The same as the Grid workaround, but with correct easing because the transition range matches the content range.

Panel switching

Switch between two panels that are both non-zero size. For example from a 200px panel to a 400px panel. The container animates smoothly between the values. In the mean time, transitions can be used for the inner panels.

CSS variables can be used to coordinate the container’s size change with the inner panels' content change. It just makes the developer’s life easier.

Unstyled by design

Most animation components come bundled with a design system: colours, borders, spacing, and component structure. Getting your own design in there means fighting the library’s CSS. Kill the Chakra.

Component libraries

CSS
/* You want this */
.accordion { border-radius: 12px; }
.accordion-trigger { background: none; text-align: left; }

/* You get this instead */
::ng-deep .mat-expansion-panel {
	border-radius: 12px !important; /* still doesn’t work */
}
::ng-deep .mat-expansion-panel-header {
	background: none !important;
}
  • Opinionated design system
  • Specificity wars and !important
  • Styles break on library updates
  • Hard to use with Tailwind or CSS Modules

Panel / PanelSet

CSS
/* Zero CSS from the library */
.accordion { border-radius: 12px; }
.accordion-trigger { background: none; text-align: left; } /* just works */
  • No styling CSS included
  • No specificity conflicts
  • Works with Tailwind, CSS Modules, CSS-in-JS, anything
  • Your design system, not the library’s

FAQ (or:  Some remarks that people might have)

Check the code again: it is actually used.

Both Panel and PanelSet use interpolate-size: allow-keywords as a “progressive enhancement” for opening and closing. Not all browsers support it. If you had checked the CSS, you would have seen that.

With interpolate-size: allow-keywords, CSS can animate directly between 0 and height: auto. If your browser supports that, then the LOCK, MEASURE and UNLOCK steps from the LMAU cycle are simply skipped. That leaves the ANIMATE step. The JavaScript (yeah, it is still needed) detects support with CSS.supports() and then does far less: it just toggles state classes and listens to events. If your browser does not support it, it uses the normal JavaScript LMAU cycle.

CSS
@supports (interpolate-size: allow-keywords) {
		[data-panelset].is-opening { height: auto; }
		[data-panelset].is-closing { height: 0; }
}

Inner panel swapping

For the inner panel swapping of PanelSet, the JavaScript LMAU cycle is still needed. interpolate-size can animate between 0 and auto, but not between two non-zero auto values.

Yeah it was considered

View Transitions allows animating between different DOM states, and it can handle size changes. But VT wants to controle the whole visual transition in a before/after snapshot and can’t work with CSS variables.

Maybe later when Level 2 element-scoped transitions mature and allow more control.

Indeed there are styles

Those are just the documentation styles. Here is what Panel and PanelSet look like with nothing added:

Panel

Key Features

  • This is our most important feature ever!

PanelSet

Key Features

  • This is our most important feature ever!

Support this project

PanelSet is free. If it saved you time, a coffee goes a long way.

Support my work on Ko-fi