Collapsible
An interactive component which expands/collapses a panel.
Recovery keys
alien-bean-pasta
wild-irish-burrito
horse-battery-staple
Codeapp/demos/public/collapsible.tsx
import { clientEntry, type Handle } from 'remix/component'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@stemsui/registry/ui/collapsible.tsx'
const rowClass = 'rounded-md border px-4 py-2 font-mono text-sm shadow-xs'
export const CollapsibleDemo = clientEntry(
import.meta.url,
function CollapsibleDemo(handle: Handle) {
let open = false
return () => (
<Collapsible
class="flex w-[350px] flex-col gap-2"
open={open}
onOpenChange={(next) => {
open = next
void handle.update()
}}
>
<div class="flex items-center justify-between gap-4 px-4">
<h4 class="text-sm font-semibold">Recovery keys</h4>
<CollapsibleTrigger
aria-label="Toggle recovery keys"
class="inline-flex size-8 items-center justify-center rounded-md hover:bg-accent hover:text-accent-foreground"
>
<ChevronsUpDownIcon />
</CollapsibleTrigger>
</div>
<div class={rowClass}>alien-bean-pasta</div>
<CollapsibleContent class="flex h-(--collapsible-panel-height) flex-col gap-2 overflow-hidden transition-[height] duration-200 ease-out data-starting-style:h-0 data-ending-style:h-0 motion-reduce:transition-none">
<div class={rowClass}>wild-irish-burrito</div>
<div class={rowClass}>horse-battery-staple</div>
</CollapsibleContent>
</Collapsible>
)
},
)
function ChevronsUpDownIcon() {
return () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
class="size-4"
>
<path d="m7 15 5 5 5-5" />
<path d="m7 9 5-5 5 5" />
</svg>
)
}
Installation
npx shadcn add @stemsui/collapsibleRequires the one-time setup. Behavior: stems-ui/collapsible.
API reference
The styled component is built on the headless module below: its props mirror these options, and the same events and data attributes apply.
stems-ui/collapsible
collapsible wires a trigger button to a panel that opens and closes, with height-animation
hooks. App code owns the markup and visual styles.
Base UI equivalent: Collapsible.
Usage
import * as collapsible from 'stems-ui/collapsible'
function RecoveryKeys() {
return () => (
<collapsible.Context defaultOpen={false}>
<div mix={collapsible.root()}>
<button mix={collapsible.trigger()}>Recovery keys</button>
<div
class="h-(--collapsible-panel-height) overflow-hidden transition-[height] duration-150 data-starting-style:h-0 data-ending-style:h-0 [&[hidden]:not([hidden=until-found])]:hidden"
mix={collapsible.panel()}
>
<div>alien-bean-pasta</div>
<div>wild-irish-burrito</div>
</div>
</div>
</collapsible.Context>
)
}
Control the state with open and onOpenChange, or let the context own it with defaultOpen.
Listen for bubbling changes on the root or an ancestor:
<div mix={collapsible.onCollapsibleChange((event) => console.log(event.open))}>…</div>
stems-ui/collapsible
Context: provider that owns the open state.open:boolean. Controlled open state.defaultOpen:boolean. Initial uncontrolled state. Defaults tofalse.onOpenChange(open, event?): called when the trigger is pressed, or when find-in-page reveals ahiddenUntilFoundpanel.disabled:boolean. Disables the trigger. Defaults tofalse.
root(): optional root element. Sets state attributes and is where change events are dispatched from (without it they're dispatched from the trigger or panel).trigger(): the toggle button. Setstype="button",aria-expanded,aria-controls, and nativedisabled. Non-<button>hosts getrole="button",tabIndex,aria-disabled, and Enter/Space activation.panel(options?): the collapsible content. Setsid,hiddenwhile closed, and the transition attributes and size variables below.hiddenUntilFound:boolean. Closed panels usehidden="until-found", so the browser's find-in-page can search them. A match opens the panel (without animation) and callsonOpenChange(true, event). Defaults tofalse.keepMounted: accepted for Base UI parity. Has no effect: the panel is app-owned markup, so it always stays in the DOM and closed panels are hidden withhidden.
onCollapsibleChange(handler): listener mixin forCollapsibleChangeEvent(stems:collapsible-change, bubbles, hasopen).
Behavior notes
- Closed panels get the
hiddenattribute in server HTML and on the client, so they're hidden even without CSS. Classes that setdisplayoverridehidden: add[&[hidden]:not([hidden=until-found])]:hidden(Tailwind) when the panel has a display class. - Opening: the panel is shown with
data-starting-style,--collapsible-panel-heightis set to the measured content height in px, and the attribute is removed on the next frame so CSS can transition from the starting style. When animations finish, the variables becomeautoso the panel follows content size changes. - Closing: the variables are set to the current px size,
data-ending-styleis added, and the panel is hidden once its CSS transitions and animations finish (element.getAnimations()). Reopening mid-close reverses from the current size. beforematch(find-in-page, fragment navigation) opens ahiddenUntilFoundpanel even when the collapsible isdisabled. If a controlled owner doesn't open it,hidden="until-found"is restored.- Explicit host props win (
attrs()semantics). Changing the panelidbreaks the trigger'saria-controls, so leave it generated. - No keyboard handling beyond the native button: Enter and Space toggle.
Styling hooks
| Part | Attribute | Values / notes |
|---|---|---|
| root | data-open | present when open |
| root | data-closed | present when closed |
| root | data-disabled | present when disabled |
| trigger | data-panel-open | present when open |
| trigger | data-disabled | present when disabled |
| trigger | aria-expanded | "true" | "false" |
| panel | data-open | present when open |
| panel | data-closed | present when closed (also while animating out) |
| panel | data-starting-style | present on the first frame of opening |
| panel | data-ending-style | present while animating out |
| panel | hidden | present when closed; "until-found" if enabled |
| Part | CSS variable | Value |
|---|---|---|
| panel | --collapsible-panel-height | content height in px while animating; |
auto once open | ||
| panel | --collapsible-panel-width | content width, same rules |