react-collapsible/Concepts/How it works
Concepts~6 min

How it works

One CSS grid track animating between 0fr and 1fr — no measured heights, no JS animation loop, no ResizeObserver. This page walks the mechanism end-to-end: why the grid trick works, what the two rendered divs do, how display: none gets to participate in a transition, and the one frame of JavaScript that holds it all together.

The problem everyone measured around

height: auto is not animatable. A CSS transition needs two concrete values to interpolate between, and auto isn't one — so a panel going from height: 0 to height: auto snaps instead of sliding. For years, every collapsible component worked around this the same way: read the content's pixel height with JavaScript, write it as an inline style, animate to it, then clean up afterwards so the panel could keep resizing with its content. It works, but the animation now depends on measurement timing, resize handling, and script running at exactly the right moment. The max-height hack avoids the JavaScript and trades it for broken easing and a magic number that breaks when content grows.

The grid insight

A fr unit doesn't describe a length. It describes a proportion of the free space in a grid container — and, crucially, browsers can interpolate it. A grid track transitioning from 0fr to 1fr animates from "no share of the space" to "the full share", and the browser resolves what that means in pixels on every frame. Nobody — not the library, not your code — ever needs to know the content's height.

That is the entire trick. Collapsible renders your content inside a single grid track and lets CSS transition the track. Here it is stretched to two seconds so you can watch the track work.

grid-template-rows: 0fr

The outer div is animating its single grid row from 0fr to 1fr over two seconds. Nothing was measured — the browser resolves what 1fr means for this content on every frame of the transition.

Notice the text is fully laid out from the first frame. The track crushes and reveals it; the content itself never changes size. That is min-height: 0 on the inner wrapper doing its job.

Two divs, one track

The component renders exactly two divs. The outer is the grid container whose track animates; the inner holds your children. While open (with the defaults, direction='y' and hideMode='remove'), the structure looks like this:

html
<div id="how-it-works-panel" class="collapsible-y collapsible-y-expanded">
<div class="expander-content expander-content-open" open>
  ...your children...
</div>
</div>

The outer div owns the animation. Its base rule — the collapsed state — declares a single row track at 0fr and a transition on it:

CSSstyles.css
.collapsible-y {
display: none;
grid-template-rows: 0fr;
transition: grid-template-rows 500ms, display 500ms;
transition-behavior: allow-discrete;
overflow: hidden;
}

Two of those lines are about display — hold that thought for the next section. The rest is the grid machine: one row track, a 500ms transition on it, and overflow: hidden to clip whatever the track can't currently fit.

The inner div is the quieter half of the trick:

CSSstyles.css
.expander-content {
min-height: 0;
min-width: 0;
}

Grid items default to min-height: auto, which means an item refuses to shrink below its content's size — and a track sized 0fr can't actually crush an item that refuses to shrink. The animation would stall at the content's natural height. min-height: 0 is the inner wrapper giving the track permission to crush it all the way down, with the outer overflow: hidden clipping what no longer fits. min-width: 0 is the same permission for the horizontal direction: direction='x' runs the identical mechanism on grid-template-columns (see horizontal collapse). The -hidden class variants swap display for visibility so collapsed content stays in the DOM — that's hide modes.

Animating in and out of display: none

In the default remove mode, a collapsed panel is display: none — out of the document flow and out of the accessibility tree, which is exactly where dismissed content belongs. But display: none is historically incompatible with transitions, in both directions:

  • Closing: display is a discrete property. Flip it to none and the content vanishes at the start of the transition — yanked out instead of animated out.
  • Opening: an element entering layout from display: none has no previous style to transition from, so it appears already at its final state.

Two recent CSS features fix one direction each — one sits in the base rule you've already seen, the other in the expanded rule:

CSSstyles.css
.collapsible-y.collapsible-y-expanded {
display: grid;
grid-template-rows: 1fr;

@starting-style {
  grid-template-rows: 0fr;
}
}

transition-behavior: allow-discrete (declared back in the base rule) opts display into the transition timeline. On close, the element keeps display: grid until the transition finishes — the track gets its full 500ms to animate down to 0fr, and only then does the panel leave layout.

@starting-style supplies the missing from-state on open. When display flips from none to grid and the element enters layout, the browser first applies the @starting-style block — grid-template-rows: 0fr — and then transitions to the rule's actual value, 1fr. The open animation plays even though the element didn't exist in layout a frame earlier.

One frame of patience: the React side

CSS does the animating; the component's JavaScript is class and attribute orchestration. There's one subtlety in it. The library's own track animation is covered by @starting-style, but everything your CSS hooks into — the open attribute on the inner wrapper, and any open: / closed: conditional classes — has no such escape hatch. If those landed in the same frame the panel enters layout, the browser would never see the element in its closed-state styles, and there would be nothing for your transitions to transition from.

So the component keeps a deferred copy of open and flips it one animation frame late:

TSXcollapsible.tsx
useEffect(() => {
if (open) {
  const rafId = requestAnimationFrame(() => setDeferredOpen(true))
  return () => cancelAnimationFrame(rafId)
} else {
  setDeferredOpen(false)
}
}, [open])

On open, the browser commits one frame with the element in layout wearing its pre-transition styles; on the next frame the deferred state lands, a separate effect sets the bare open attribute on the inner wrapper, and the conditional classes resolve to their open-state values — all with a real from-state to animate away from. Closing skips the deferral entirely: the deferred state drops immediately, your closed-state styles apply at once, and allow-discrete keeps the panel in layout while the track animates shut.

One more piece of timing lives next door: initialOpen renders the panel open with a zero-duration first frame so there's no entry animation on mount. That, the open prop, and the [open] styling hook are covered in controlled state.

Browser support

The mechanism needs @starting-style and transition-behavior: allow-discrete, which means Chrome 117+, Edge 117+, Safari 17.4+, and Firefox 129+. In older browsers nothing breaks — content still shows and hides, it just snaps instead of animating. The full support table is on Installation.