react-collapsible/Why react-collapsible
Getting started~3 min

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-height hack. 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 a ResizeObserver for 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.

CSS
/* 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

One component
Fully controlled
You pass 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.

TSX
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>
  </>
)
}
Styling hooks
In the class string
open: and closed: class prefixes flip in sync with the animation.
A bare [open] attribute on the content wrapper, for plain CSS.
Two hide modes: 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.

Accessibility
Wired, then checked
Trigger contract: aria-controls matching the panel id, plus aria-expanded.
In hide mode the collapsed wrapper carries aria-hidden.
A dev-only console warning fires if no trigger references the panel.

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.