Stems UI
Menu

Collapsible

An interactive component which expands/collapses a panel.

Recovery keys

alien-bean-pasta
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/collapsible

Requires 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 to false.
    • onOpenChange(open, event?): called when the trigger is pressed, or when find-in-page reveals a hiddenUntilFound panel.
    • disabled: boolean. Disables the trigger. Defaults to false.
  • 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. Sets type="button", aria-expanded, aria-controls, and native disabled. Non-<button> hosts get role="button", tabIndex, aria-disabled, and Enter/Space activation.
  • panel(options?): the collapsible content. Sets id, hidden while closed, and the transition attributes and size variables below.
    • hiddenUntilFound: boolean. Closed panels use hidden="until-found", so the browser's find-in-page can search them. A match opens the panel (without animation) and calls onOpenChange(true, event). Defaults to false.
    • 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 with hidden.
  • onCollapsibleChange(handler): listener mixin for CollapsibleChangeEvent (stems:collapsible-change, bubbles, has open).

Behavior notes

  • Closed panels get the hidden attribute in server HTML and on the client, so they're hidden even without CSS. Classes that set display override hidden: 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-height is 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 become auto so the panel follows content size changes.
  • Closing: the variables are set to the current px size, data-ending-style is 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 a hiddenUntilFound panel even when the collapsible is disabled. If a controlled owner doesn't open it, hidden="until-found" is restored.
  • Explicit host props win (attrs() semantics). Changing the panel id breaks the trigger's aria-controls, so leave it generated.
  • No keyboard handling beyond the native button: Enter and Space toggle.

Styling hooks

PartAttributeValues / notes
rootdata-openpresent when open
rootdata-closedpresent when closed
rootdata-disabledpresent when disabled
triggerdata-panel-openpresent when open
triggerdata-disabledpresent when disabled
triggeraria-expanded"true" | "false"
paneldata-openpresent when open
paneldata-closedpresent when closed (also while animating out)
paneldata-starting-stylepresent on the first frame of opening
paneldata-ending-stylepresent while animating out
panelhiddenpresent when closed; "until-found" if enabled
PartCSS variableValue
panel--collapsible-panel-heightcontent height in px while animating;
auto once open
panel--collapsible-panel-widthcontent width, same rules