|Kit

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

Loading preview

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 Collapse animates 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 has min-width: 0: overflow: clip doesn't reset a grid item's min-width: auto (only a scroll container does), so without it a row meant to shrink — a min-w-0 flex child, a table, a long unbroken string — would widen the Collapse to 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 as hidden would. If the control that closes the panel lives inside it, move focus somewhere sensible yourself (usually the trigger).
  • The root is yours. inert sits on the inner cell, so inert, aria-* and every other div prop 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.

Changelog

v0.18.0
  • Collapse accepts asChild

    #288
v0.15.0
  • Content can shrink below its min-content width

    #232
v0.14.0
  • A closed Collapse is inert, so it leaves the tab order

    #205
v0.8.0
  • Collapse's own data-state wins over one merged in through asChild

    #120
v0.1.0
  • New Collapse

    #8

On this page