Why react-collapsible
Animate height to auto without measuring anything. One controlled component rides a CSS Grid trick, and the browser owns the motion.
The problem with animating to auto
CSS has never been able to transition height: 0 to height: auto. Every workaround pays for it somewhere:
- The
max-heighthack. Transition to a value that is "probably big enough". Guess too small and content clips; guess too large and the easing curve runs across phantom pixels, so the panel snaps open and crawls shut. - JS measurement. Read
scrollHeight, pin an explicit pixel height, transition, unset it afterwards — plus aResizeObserverfor when content changes, forced layout on every toggle, and stale numbers if the content updates mid-animation. - An animation library. Spring physics and a runtime in your bundle, for one disclosure panel.
All three work around the same gap: the browser already knows the content's intrinsic size, but CSS gave you no animatable way to ask for it.
The insight: fr tracks animate
CSS Grid closed that gap. Fractional track sizes are animatable, and a 1fr track resolves to the content's intrinsic size. So a panel that transitions grid-template-rows from 0fr to 1fr animates to exactly content height — no measurement, no guessed maximum, no JS anywhere in the motion path.
/* collapsed */ .collapsible-y { grid-template-rows: 0fr; transition: grid-template-rows 500ms; } /* expanded */ .collapsible-y.collapsible-y-expanded { grid-template-rows: 1fr; }
That is the heart of the shipped stylesheet, trimmed to the moving parts. react-collapsible adds the two details that make it production-ready: @starting-style gives the panel an entry animation when it transitions out of display: none, and transition-behavior: allow-discrete keeps display in the transition so closing content animates out instead of vanishing on the first frame. And because the track resolves size on the fly, content can change while the panel is open — nothing goes stale.
What you get
open; the component holds no open state of its own.direction='y' for accordions and panels, 'x' for sidebars.initialOpen renders open on first paint, no entry flicker.Toggling lives in your code — useState, a reducer, a store, whatever already owns the state. The component turns a boolean into motion and nothing more, which is also why it composes into accordions, sidebars, and nested panels without an orchestration layer.
import { useState } from 'react' import { Collapsible } from '@westopp/react-collapsible' const Details = () => { const [open, setOpen] = useState(false) return ( <> <button aria-controls="why-react-collapsible-details" aria-expanded={open} onClick={() => setOpen(!open)}> Details </button> <Collapsible id="why-react-collapsible-details" open={open}> <p>Any content, any height. The browser works it out.</p> </Collapsible> </> ) }
open: and closed: class prefixes flip in sync with the animation.[open] attribute on the content wrapper, for plain CSS.remove drops collapsed content from the flow, hide keeps it alive.Collapsing the container is half the effect; the content inside usually wants to fade or slide with it. Write open:opacity-100 closed:opacity-0 in className, add your own transition utility, and the fade runs in sync with the collapse. Hide modes decide what collapsed means: the default remove takes content out of layout and the accessibility tree entirely, while hide keeps the DOM alive — form state, iframes, and measurements survive — behind visibility: hidden and aria-hidden.
aria-controls matching the panel id, plus aria-expanded.hide mode the collapsed wrapper carries aria-hidden.You own the trigger, so the component can't add the ARIA wiring for you — but it can hold you to it. In development, opening a panel that no element points at via aria-controls logs a warning naming the missing id. The docs you're reading follow the same contract in every example.
The tradeoffs
It needs a modern browser to animate. @starting-style and transition-behavior: allow-discrete set the floor: Chrome and Edge 117+, Safari 17.4+, Firefox 129+. In older browsers the component still works — content shows and hides correctly — it snaps between states instead of animating. If your audience skews older than that floor, you're shipping a toggle, not a transition.
It's one component, not a disclosure framework. There is no focus trapping, no roving tabindex, no rendered trigger. You bring the button and its ARIA attributes; react-collapsible brings the motion. If you need a full headless disclosure system with keyboard orchestration, reach for one — this library deliberately stops at the collapse.
Where next
Follow getting started to build your first panel in five minutes, or read how it works for the Grid technique end to end — the 0fr track, what @starting-style buys you, and why the inner wrapper needs min-height: 0.