Collapse
Reveal or hide content with a pure-CSS height animation — a single-cell grid whose row track animates between 0fr and 1fr, so auto-height content expands and collapses cleanly both ways.
Collapse animates a section open and closed by its height — without ever measuring the DOM. It's the disclosure primitive the Notification toasts use to reveal their description and action independently, extracted so you can reuse it anywhere (accordion rows, "show more" panels, inline detail).
The trick is a single-cell grid whose row track animates between 0fr (collapsed) and 1fr (revealed). Because the track is a fraction, it resolves to the content's own height — so auto-height content animates in and out with a plain CSS transition, no ResizeObserver, no height reads, no jump.
import { Collapse } from '@42-central/ui-react/collapse';
const [open, setOpen] = useState(false);
<button onClick={() => setOpen((o) => !o)}>Toggle</button>
<Collapse open={open}>
<p>Any content — it reveals and collapses at its natural height.</p>
</Collapse>Playground
Toggle open and watch the content animate. It's a Server Component (state lives in the parent), so there's no hydration cost.
Component
Dir
Presets
Props
Code
<Component open>
This content reveals and collapses with a height animation.
</Component>How it animates
The outer element is the animated grid (grid-template-rows: 0fr → 1fr); the inner cell has min-height: 0 and clips its overflow, so the content slides under the fold as the track opens. Two things follow from that:
- Independent sections. Each
Collapseanimates on its own timeline, so siblings (say a description and an action row) reveal separately rather than fighting over one shared height. - Reduced motion. The transition is dropped under
prefers-reduced-motion, so the toggle is instant. - It shrinks like a plain
div. The cell also hasmin-width: 0:overflow: clipdoesn't reset a grid item'smin-width: auto(only a scroll container does), so without it a row meant to shrink — amin-w-0flex child, a table, a long unbroken string — would widen theCollapseto its min-content width and overflow the parent.
Override the timing with a duration-* / ease-* class on className (tailwind-merge lets the later class win), and style the sliding region — padding, layout — with classNames.content:
<Collapse open={open} className="duration-500" classNames={{ content: "flex flex-col gap-2 pt-2" }}>
<Detail />
<Detail />
</Collapse>Closed content is inert
The thing Collapse usually replaces is hidden={!open} — and hidden takes content out of the tab order and out of the accessibility tree. overflow-clip does neither: it is purely visual. So a closed panel marks its clipped cell inert, and the two behave the same way:
// Both of these are unreachable by keyboard and unannounced by a screen reader:
<div hidden={!open}>…</div>
<Collapse open={open}>…</Collapse>Without it, a folded section's buttons and links stay perfectly focusable at zero height — tab order runs straight through them into controls nobody can see, which is invisible until someone reaches for the keyboard.
The switch is instantaneous: the content goes inert in the same frame open flips to false, while the row track is still shrinking. That is deliberate — closing means unreachable straight away, so focus can never enter something on its way out. Only the animation lags behind open; the children stay mounted throughout.
Two consequences worth knowing:
- Focus inside a panel you close is dropped. The browser moves it to
<body>, exactly ashiddenwould. If the control that closes the panel lives inside it, move focus somewhere sensible yourself (usually the trigger). - The root is yours.
inertsits on the inner cell, soinert,aria-*and every otherdivprop you pass still land on the grid root untouched.
Keeping content mounted through the exit
Collapse animates its own height, but if you unmount the children the moment you close it, there's nothing left to animate out — it snaps. Keep the last content mounted through the collapse (a ref cache is enough) so the exit has something to shrink:
const lastDetail = useRef(detail);
if (detail != null) lastDetail.current = detail;
<Collapse open={detail != null}>{detail ?? lastDetail.current}</Collapse>Wiring an accessible trigger
Collapse is presentation-only — beyond going inert while closed, it doesn't render a trigger or manage aria-expanded/aria-controls for you. For a single toggle (a "show more" link, an "advanced options" disclosure), pair it with the useDisclosure hook — a small open/close/toggle state helper — and wire the ARIA attributes onto your own button by hand:
import { useDisclosure } from '@42-central/ui-react';
import { Collapse } from '@42-central/ui-react/collapse';
const [opened, { toggle }] = useDisclosure();
<button aria-expanded={opened} aria-controls="advanced-options" onClick={toggle}>
Advanced options
</button>
<Collapse id="advanced-options" open={opened}>
<AdvancedOptionsForm />
</Collapse>For a list of named, expandable sections (an FAQ, a settings list), reach for Accordion instead — it already wires this same trigger/content relationship (plus keyboard nav and single/multiple-open semantics) using Collapse internally.
useDisclosure
Prop
Type
Returns [opened, { open, close, toggle }] — uncontrolled only, no value prop. Import it from the package root: import { useDisclosure } from '@42-central/ui-react'.
API
Prop
Type
Also accepts any div prop (id, role, style, event handlers, …) plus testId, forwarded to the grid root.