Navigation
Collapsible
One thing that opens, on its own.
Usage
When to reach for it
import { Collapsible } from '@misoto22/design'Notes
Radix Collapsible root, as a typed passthrough.
Anatomy
| Element | Description |
|---|---|
| Rootrequired | Collapsible — Radix’s root, holding open or defaultOpen and drawing nothing. CollapsibleSection is the same root with the trigger and panel already composed, and is what most call sites want. |
| Triggerrequired | A plain <button> carrying aria-expanded and aria-controls — and, unlike an accordion row, wrapped in no heading at all. Nothing here appears in a document outline. |
| Markerrequired | A chevron rotating 180°, chosen against the accordion’s plus on purpose: this reveals more of the same thing, where an accordion row opens a distinct answer. |
| Panel | Radix’s Content, unmounted while closed and animating on the measured --radix-collapsible-content-height, so a long group and a short one take the same time. A bare div: no region role and no name of its own, again unlike an accordion’s. Both this and the loose CollapsibleContent carry data-m22-animated, so the two agree under prefers-reduced-motion rather than only one of them honouring it. |
Best practices
Do
- Give the trigger a heading of your own when the section is a section: Accordion wraps every trigger in an <h3> and this deliberately wraps none, so a page built from CollapsibleSections has nothing for heading navigation to stop at.
- Reach for the loose CollapsibleTrigger and CollapsibleContent only when the header has to hold more than a title — a count on one side, a switch on the other. They exist so that call site does not go to Radix and re-derive aria-expanded by hand.
- Set defaultOpen when what it hides is why the reader came: the closed panel is unmounted rather than hidden, so its text is not in the page for find-in-page, for a print, or for anything reading the rendered DOM.
- Control it with open and onOpenChange when something outside has to open it — a sidebar group that must expand for the route living inside it cannot be told to from a component that owns its own state.
Don’t
- Do not build a set out of these: two sections cannot close each other, so the reader ends with every one open and a column to scroll past. That coordination is the whole of what Accordion’s single value buys.
- Do not flip the title between “Show more” and “Show less”: aria-expanded on the trigger already carries the state, so the row is announced with its state twice and with a new name each time it is pressed.
Examples
default
One thing that opens, on its own. CollapsibleSection is the composed root — trigger, marker and panel already wired — and is what most call sites want. The marker is a chevron rather than the accordion's plus, deliberately: this reveals more of the same thing, where an accordion row opens a distinct answer. The trigger is wrapped in no heading at all, unlike an accordion's, so a page built out of these gives heading navigation nothing to stop at — add a heading of your own when the section is a section. And do not flip the title between Show more and Show less: aria-expanded already carries the state, so the row would be announced with its state twice and under a new name each time it is pressed.
a custom header
The loose trigger and panel, for a header that has to hold more than a title — a count on one side, the control on the other. They exist so this call site does not go to Radix directly and re-derive aria-expanded and aria-controls by hand. Two things change when you leave the composed section behind: the trigger is an icon button, so it needs an accessible name of its own, and the panel is a bare div with no region role and no name. defaultOpen is set because what this hides is why the reader came — a closed panel is unmounted rather than hidden, so its text is not in the page for find-in-page or for a print.
- typecheck — 2 errors in apps/docs/src/lib/docs.ts
- lint — unused import in Toolbar.tsx
- visual diff — 1 changed screenshot
sidebar group
Controlled, because something outside has to be able to open it: the group holding the current route must already be expanded when the reader arrives, and a component that owns its own state cannot be told that. Do not build a set out of these — two sections cannot close each other, so the reader ends with every group open and a column to scroll past. That coordination is the whole of what an accordion's single value buys, and it is the reason to reach for one instead once there are three groups.
Parts
Composed at the call site rather than configured through props, so a layout this component did not anticipate is still expressible.
CollapsibleContent
Takes no props of its own.
CollapsibleSection
One thing that opens, on its own.
The difference from Accordion is arithmetic: an accordion is a SET, and a set can coordinate — opening one closes another. A collapsible is one disclosure with nothing to coordinate with. Reaching for an accordion of one gets you a component managing a value you never read.
The marker here is a chevron rather than the accordion's plus, and deliberately: this reveals more of the same thing, where an accordion row opens a distinct answer.
| Prop | Type | Default | Description |
|---|---|---|---|
| childrenrequired | ReactNode | — | |
| titlerequired | ReactNode | What the trigger says. |
Also accepts everything in Omit<ComponentProps<typeof CollapsiblePrimitive.Root>, 'children' | 'title'>. Those are forwarded to the underlying element and are not listed row by row.
Re-exports
Collapsible = CollapsiblePrimitive.RootRadix Collapsible root, as a typed passthrough.
CollapsibleTrigger = CollapsiblePrimitive.TriggerThe trigger and the panel, unstyled, for a disclosure that needs its own layout — a sidebar group whose header carries a count and a chevron on opposite sides, say. CollapsibleSection is the composed version and is what most call sites want; these two exist so the ones that do not have to reach for Radix directly and re-derive the keyboard and aria-expanded wiring.
The panel animates on --radix-collapsible-content-height, which Radix measures — so it opens to its real height rather than to a guessed max-height, which is what makes a long group and a short one take the same time instead of the long one appearing to stall.
Keyboard
| Key | Does |
|---|---|
| EnterSpace | Opens or closes it. |