Docs Platform

Collapsible

The simplest disclosure in the platform: one trigger, one region, one open/closed boolean. The machine decides whether the region is open and whether the trigger can even be pressed, the headless layer wires a native button to a labelled region, and the styled layer adds tokens and classes. There is no overlay, portal, focus trap or scroll lock anywhere in it. Nothing moves focus, and nothing leaves normal page flow.

Explore the layers

Machine (@grassroot/ui-machines/collapsible) is pure state: no DOM, no framework, no styling. Headless (@grassroot/ui-headless-<framework>/collapsible) adds a native <button>/role="region" pair with accessible DOM/ARIA on top of the same machine, per framework. Styled (@grassroot/ui-<framework>) is Grassroot's own visual recipe built on the headless layer. Vanilla (mountCollapsible, from @grassroot/ui-headless-core/collapsible) is the framework-free mount helper.

Layer
Package
Ships behaviour
Machine
@grassroot/ui-machines/collapsible
Open binding, disabled guard, no DOM.
Headless
@grassroot/ui-headless-{react,vue,solid,svelte,angular}/collapsible
Native button, region role, ARIA relationship, hidden attribute, no visual recipe.
Vanilla
@grassroot/ui-headless-core/collapsible (mountCollapsible)
The same headless contract with no framework runtime at all.
Styled
@grassroot/ui-{react,vue,solid,svelte,angular}
Grassroot's disclosure visual recipe, tokens and classes only.
Layer
Machine
Package
@grassroot/ui-machines/collapsible
Ships behaviour
Open binding, disabled guard, no DOM.
Layer
Headless
Package
@grassroot/ui-headless-{react,vue,solid,svelte,angular}/collapsible
Ships behaviour
Native button, region role, ARIA relationship, hidden attribute, no visual recipe.
Layer
Vanilla
Package
@grassroot/ui-headless-core/collapsible (mountCollapsible)
Ships behaviour
The same headless contract with no framework runtime at all.
Layer
Styled
Package
@grassroot/ui-{react,vue,solid,svelte,angular}
Ships behaviour
Grassroot's disclosure visual recipe, tokens and classes only.

Behaviour contract

This is the one thing every layer agrees on. The machine decides it, the headless layer exposes it as DOM/ARIA, and the styled layer inherits it unchanged.

CollapsibleInput: open? / defaultOpen?  (ONE state-backed binding; default false)
                  disabled?: boolean
StateValue:       "open" | "closed"
Context:          { mode: "controlled" | "uncontrolled" }
Events:           TOGGLE { reason: "pointer" | "keyboard" | "programmatic" }
                  RESET
Command:          OPEN_CHANGE_REQUEST { open: boolean, reason }
  • One binding, same shape as Switch. open !== undefined fixes controlled or uncontrolled mode at first start, recomputable while idle. A post-start mode flip leaves state unchanged and reports "Collapsible cannot change open binding mode after start" , the same diagnostic every other layered component emits for the same mistake.
  • Uncontrolled TOGGLE commits the next state and emits exactly one OPEN_CHANGE_REQUEST.
  • Controlled TOGGLE leaves the authoritative state untouched and emits one request with the proposed value, which is a command-only transition. Repeated toggles keep proposing the same value rather than drifting locally.
  • Disabled input makes TOGGLE a true no-op in either mode: same state reference, no command.
  • RESET restores uncontrolled defaultOpen with no command; controlled state is always a true no-op.
  • Forms: none.

Anatomy

The headless layer's parts, with identical part names across every framework adapter:

Collapsible.Root      data-scope="collapsible" data-part="root"
Collapsible.Trigger   data-part="trigger"  (native <button type="button">)
Collapsible.Content   data-part="content"  (role="region")

All parts carry data-scope="collapsible" and data-part; Root, Trigger, and Content all carry data-state="open" | "closed". Trigger's id and Content's id are both generated from the part-scope seed: the public id prop a consumer supplies stays a plain attribute on the styled root and never controls the headless part ids.

Trigger is aria-expanded (the stringified committed open value) and aria-controls pointing at Content's id; native disabled is present when the input is disabled. Content is aria-labelledby pointing back at Trigger's id.

Content stays mounted in both states. It is never removed from the DOM. Unlike Dialog's overlay parts or Accordion's closed panels, Collapsible's Content is a plain hidden toggle: closed Content receives hidden: true, open Content receives hidden: undefined, and this key is always emitted (even when clearing it) so adapters that don't diff omitted keys still remove the attribute correctly when the region opens. Height and opacity animation stay a pure styling concern, and the headless connector never measures or reads layout.

Root is wrapper semantics only. There is no overlay, portal, layer, focus trap, or scroll lock anywhere in this component.

Keyboard and pointer

The native button is the single activation path. No custom keydown handler exists anywhere in this component, and there is no roving focus or focus manager to reason about.

Trigger
When
Effect
Enter / Space
trigger focused
Native button activation toggles the disclosure, classified reason: "keyboard" (a click with detail === 0).
Pointer click
trigger
Toggles once, classified reason: "pointer".
Any activation
disabled
True no-op, because native disabled on the button prevents the click from ever firing.
Trigger
Enter / Space
When
trigger focused
Effect
Native button activation toggles the disclosure, classified reason: "keyboard" (a click with detail === 0).
Trigger
Pointer click
When
trigger
Effect
Toggles once, classified reason: "pointer".
Trigger
Any activation
When
disabled
Effect
True no-op, because native disabled on the button prevents the click from ever firing.

Forms

None.

Accessibility

Trigger is a real <button type="button"> with aria-expanded and aria-controls; Content is role="region" with aria-labelledby pointing back at Trigger, which gives a labelled landmark region that a screen reader can jump straight to, whether it happens to be open or closed at the time. connectCollapsible and every prop getter are callable with no document, window, timers, or mounted elements present; part registration happens only through the adapter's own mount/render refs, so server-rendered markup and the first client render agree with no hydration mismatch.

Evidence & further reading