- Layer
- Machine
- Package
@grassroot/ui-machines/collapsible- Ships behaviour
- Open binding, disabled guard, no DOM.
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.
@grassroot/ui-machines/collapsible@grassroot/ui-headless-{react,vue,solid,svelte,angular}/collapsiblehidden attribute, no visual recipe.@grassroot/ui-headless-core/collapsible (mountCollapsible)@grassroot/ui-{react,vue,solid,svelte,angular}- Layer
- Headless
- Package
@grassroot/ui-headless-{react,vue,solid,svelte,angular}/collapsible- Ships behaviour
- Native button, region role, ARIA relationship,
hiddenattribute, 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 !== undefinedfixes 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
TOGGLEcommits the next state and emits exactly oneOPEN_CHANGE_REQUEST. -
Controlled
TOGGLEleaves 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
TOGGLEa true no-op in either mode: same state reference, no command. -
RESETrestores uncontrolleddefaultOpenwith 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.
Enter / Spacereason: "keyboard" (a click with detail === 0).reason: "pointer".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 withdetail === 0).
- Trigger
- Pointer click
- When
- trigger
- Effect
- Toggles once, classified
reason: "pointer".
- Trigger
- Any activation
- When
- disabled
- Effect
- True no-op, because native
disabledon 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
-
collapsible.contract.ts, the shared browser-contract suite, implemented per adapter in each framework's ownbrowser/collapsible.spec.ts(React, Vue, Solid, Svelte, Angular) plus the core vanilla mount. -
collapsible.spec.md, the reviewable anatomy/ARIA/keyboard/forms/SSR contract; code must match it, and where they disagree the spec wins. -
Build your own component on
@grassroot/ui-headless-core, the same machine → part scope → connect → commands recipe this page walks through for Collapsible, applied to a component that isn't one of the shipped machines. - Accordion: the multi-panel sibling, worked through its own anatomy
- Switch: the same one-binding shape, applied to a toggle rather than a disclosure