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: 0frTwo 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:
<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:
.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:
.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:
displayis a discrete property. Flip it tononeand the content vanishes at the start of the transition — yanked out instead of animated out. - Opening: an element entering layout from
display: nonehas 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:
.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:
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.