@loke/ui
Version:
205 lines (159 loc) • 6.58 kB
Markdown
---
name: collapsible
type: core
domain: navigation
requires: [loke-ui]
description: >
Single collapsible section with Collapsible/CollapsibleTrigger/CollapsibleContent.
CSS variable animation via --loke-collapsible-content-height and
--loke-collapsible-content-width (measured via ResizeObserver-style layout effect).
forceMount for exit animations. Controlled via open/onOpenChange.
For multiple coordinated sections, use Accordion (which wraps Collapsible internally).
---
# Collapsible
## Setup
A collapsible section with a trigger button and hidden content.
```tsx
import {
Collapsible,
CollapsibleTrigger,
CollapsibleContent,
} from "@loke/ui/collapsible";
function FilterPanel() {
return (
<Collapsible defaultOpen>
<CollapsibleTrigger>Advanced filters</CollapsibleTrigger>
<CollapsibleContent>
<div>
<label>
<input type="checkbox" /> Include archived
</label>
<label>
<input type="checkbox" /> Show drafts
</label>
</div>
</CollapsibleContent>
</Collapsible>
);
}
```
`CollapsibleTrigger` renders a `<button>` with `aria-expanded` and `aria-controls` wired automatically.
## Core Patterns
### CSS variable animation
`CollapsibleContent` measures the real content dimensions before each open/close transition and exposes them as CSS custom properties. Use `--loke-collapsible-content-height` for smooth height animations — never use a fixed `max-height`.
```css
.collapsible-content {
overflow: hidden;
}
.collapsible-content[data-state="open"] {
animation: collapsible-open 200ms ease-out;
}
.collapsible-content[data-state="closed"] {
animation: collapsible-close 200ms ease-in;
}
@keyframes collapsible-open {
from { height: 0; }
to { height: var(--loke-collapsible-content-height); }
}
@keyframes collapsible-close {
from { height: var(--loke-collapsible-content-height); }
to { height: 0; }
}
```
`data-state` is `"open"` or `"closed"` on both `Collapsible` and `CollapsibleContent`.
```tsx
<Collapsible>
<CollapsibleTrigger className="collapsible-trigger">
Show more
</CollapsibleTrigger>
<CollapsibleContent className="collapsible-content">
Additional content here.
</CollapsibleContent>
</Collapsible>
```
Source: `src/components/collapsible/collapsible.tsx` — `CollapsibleContentImpl` measures `getBoundingClientRect()` and sets CSS variables in a layout effect.
### Exit animations with `forceMount`
By default `CollapsibleContent` unmounts when closed, cutting off any exit animation. Use `forceMount` to keep the content in the DOM through the close animation. The component tracks `isPresent` internally via `Presence`.
```tsx
<Collapsible>
<CollapsibleTrigger>Toggle</CollapsibleTrigger>
<CollapsibleContent forceMount className="collapsible-content">
This content stays mounted for exit animations.
</CollapsibleContent>
</Collapsible>
```
Source: `src/components/collapsible/collapsible.tsx` — `Presence` with `forceMount || context.open`.
### Controlled state
```tsx
const [open, setOpen] = useState(false);
<Collapsible open={open} onOpenChange={setOpen}>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span>Repositories</span>
<CollapsibleTrigger>
{open ? "Hide" : "Show"} more
</CollapsibleTrigger>
</div>
<CollapsibleContent>
<ul>
<li>repo-one</li>
<li>repo-two</li>
</ul>
</CollapsibleContent>
</Collapsible>
```
### Disabled state
```tsx
<Collapsible disabled>
<CollapsibleTrigger>Locked section</CollapsibleTrigger>
<CollapsibleContent>Cannot be toggled.</CollapsibleContent>
</Collapsible>
```
`data-disabled=""` is set on both `Collapsible` and `CollapsibleContent` when disabled.
## Common Mistakes
### Using Collapsible for multiple expandable sections — use Accordion instead
`Collapsible` manages a single open/closed state. If you need multiple expandable sections — especially with single-selection (one open at a time) — use `Accordion`. Accordion wraps Collapsible internally and adds coordinated state, keyboard navigation (Home/End/Arrow), and grouped ARIA semantics.
```tsx
// Wrong — manual coordination of multiple Collapsibles
<Collapsible open={openA} onOpenChange={setOpenA}>...</Collapsible>
<Collapsible open={openB} onOpenChange={setOpenB}>...</Collapsible>
// Correct — use Accordion for coordinated sections
import { Accordion, AccordionItem, AccordionHeader, AccordionTrigger, AccordionContent } from "@loke/ui/accordion";
<Accordion type="single" collapsible>
<AccordionItem value="a">
<AccordionHeader><AccordionTrigger>Section A</AccordionTrigger></AccordionHeader>
<AccordionContent>Content A</AccordionContent>
</AccordionItem>
<AccordionItem value="b">
<AccordionHeader><AccordionTrigger>Section B</AccordionTrigger></AccordionHeader>
<AccordionContent>Content B</AccordionContent>
</AccordionItem>
</Accordion>
```
Source: `src/components/accordion/accordion.tsx` — Accordion wraps Collapsible with coordinated state.
### Hardcoded `max-height` for animation — janky transitions
The component measures actual content height at runtime. A fixed `max-height` value won't match the content and produces uneven easing.
```css
/* Wrong */
.collapsible-content[data-state="open"] {
max-height: 300px;
transition: max-height 200ms;
}
/* Correct */
.collapsible-content[data-state="open"] {
height: var(--loke-collapsible-content-height);
}
```
Source: `src/components/collapsible/collapsible.tsx` — `--loke-collapsible-content-height` set from `getBoundingClientRect().height`.
### Expecting open animation on initial render with `defaultOpen={true}`
`CollapsibleContent` suppresses mount animation via `isMountAnimationPreventedRef`. When the component first renders with `defaultOpen={true}`, content appears instantly without animating in — this is intentional to avoid animation on page load.
```tsx
// Content appears instantly on first render — this is expected
<Collapsible defaultOpen>
<CollapsibleTrigger>Section</CollapsibleTrigger>
<CollapsibleContent>Initially visible, no entry animation.</CollapsibleContent>
</Collapsible>
```
Source: `src/components/collapsible/collapsible.tsx` — `isMountAnimationPreventedRef` cleared after first `requestAnimationFrame`.
## Cross-references
- **Accordion** (`@loke/ui/accordion`) — wraps Collapsible; use for multiple coordinated sections
- **Choosing the Right Component** — Collapsible vs Accordion decision guidance