# Collapse (/docs/collapse)





`Collapse` animates a section open and closed by its height — without ever measuring the DOM. It's the disclosure primitive the [`Notification`](/docs/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.

```tsx
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 [#playground]

Toggle `open` and watch the content animate. It's a Server Component (state lives in the parent), so there's no hydration cost.

<story.Playground />

## How it animates [#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`:

```tsx
<Collapse open={open} className="duration-500" classNames={{ content: "flex flex-col gap-2 pt-2" }}>
  <Detail />
  <Detail />
</Collapse>
```

## Closed content is inert [#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:

```tsx
// 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 [#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:

```tsx
const lastDetail = useRef(detail);
if (detail != null) lastDetail.current = detail;

<Collapse open={detail != null}>{detail ?? lastDetail.current}</Collapse>
```

## Wiring an accessible trigger [#wiring-an-accessible-trigger]

`Collapse` is presentation-only — beyond going [inert while closed](#closed-content-is-inert), 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`](/docs/collapse#usedisclosure) hook — a small `open`/`close`/`toggle` state helper — and wire the ARIA attributes onto your own button by hand:

```tsx
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`](/docs/accordion) instead — it already wires this same trigger/content relationship (plus keyboard nav and single/multiple-open semantics) using `Collapse` internally.

### `useDisclosure` [#usedisclosure]

<TypeTable
  type="{
  initialOpen: { type: 'boolean', default: 'false', description: 'The initial state.' },
  'callbacks.onOpen': { type: '() => void', description: 'Called after the state changes to true.' },
  'callbacks.onClose': { type: '() => void', description: 'Called after the state changes to false.' },
}"
/>

Returns `[opened, { open, close, toggle }]` — uncontrolled only, no `value` prop. Import it from the package root: `import { useDisclosure } from '@42-central/ui-react'`.

## API [#api]

<TypeTable
  type="{
  open: { type: 'boolean', description: 'Whether the content is revealed. Also mirrored as data-state=&#x22;open&#x22; | &#x22;closed&#x22;. While false, the content is inert — out of the tab order and out of the accessibility tree.' },
  classNames: { type: '{ root?: string; content?: string }', description: 'Extra classes for the animated grid root (root) and the inner, clipped cell that holds the content (content).' },
  className: { type: 'string', description: 'Classes for the animated grid root. Override the timing here (duration-*, ease-*).' },
  children: { type: 'ReactNode', description: 'The content to reveal.' },
}"
/>

Also accepts any `div` prop (`id`, `role`, `style`, event handlers, …) plus `testId`, forwarded to the grid root.
