- Keys
Enter / Space- When
- any trigger, focused
- Effect
- Toggles that item (native button activation, legacy-compatible).
Docs Platform
Accordion
One behaviour contract, four layers. Pick a layer, and a framework where that applies, and the page swaps what it shows you: the state machine, its accessible DOM and ARIA translation, the styled component, or the framework-free mount. What it means stays put. The behaviour contract further down holds whichever layer you picked, which is why it's written out once instead of four times.
Explore the layers
Machine is pure state: no DOM, no framework, no styling. Headless adds accessible DOM/ARIA on top of the same machine, per framework. Styled is Grassroot's own visual recipe built on the headless layer. Vanilla is the framework-free mount helper, for script islands and anywhere else with no framework to hand.
Install
npm i @grassroot/ui-machines
import { createService } from "@grassroot/statechart"import { accordionMachine, accordionSelectors, type AccordionCommand } from "@grassroot/ui-machines/accordion" const items = [ { value: "shipping", disabled: false }, { value: "returns", disabled: false }, { value: "warranty", disabled: true },] const changes: AccordionCommand[] = []const service = createService(accordionMachine, { input: { items, type: "single", defaultValue: ["shipping"], collapsible: true }, runCommand: (change) => { changes.push(change) },}) service.start()service.send({ type: "MOVE", direction: 1 }) // focus "returns" (no toggle)service.send({ type: "TOGGLE", value: "returns", reason: "keyboard" }) accordionSelectors.value(service.getSnapshot()) // ["returns"], single mode replaces the open itemchanges.at(-1) // VALUE_CHANGE_REQUEST for ["returns"], reason "keyboard"service.stop()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.
-
Events:
TOGGLE{ value, reason },MOVE{ direction: -1 | 1 },HOME,END,FOCUS{ value },BLUR,RESET. -
Value binding: the committed value is an
array (
readonly TValue[]), not a single value. UncontrolledTOGGLEcommits and emits oneVALUE_CHANGE_REQUESTwith the committed open list and reason; controlled variants leave the authoritative value unchanged (still trackfocusedValue) and emit the request with the proposed list. Flipping controlled ↔ uncontrolled after start produces abinding-mode-changediagnostic, the same S12 contract Select and Tabs follow. Sync is reference comparison only: the bound array is adopted by reference, never deep-compared. -
Reasons: every change request carries
"pointer" | "keyboard" | "programmatic", so consumers can distinguish how a value changed. -
Commands:
VALUE_CHANGE_REQUEST(the single change event, fired exactly once per user toggle) andFOCUS_ITEM{ key: value } (resolved through the part scope, silent no-op when unmounted; the header's roving-focus target). Navigation (MOVE/HOME/END) only ever emitsFOCUS_ITEM; it never toggles. - Forms: none.
-
Disabled items: guarded by
TOGGLE/MOVE/HOME/END/FOCUSin the machine itself. The legacy per-adapter disabled check is deleted. The headless trigger is a nativedisabledbutton, so no click or focus reaches it either.
Items travel in machine input, not event payloads: value /
defaultValue pick controlled vs. uncontrolled mode at first
start (the bound value is the full open array), type picks
single vs. multiple mode, and collapsible opts single mode
out of always-closeable (default true, for legacy parity).
Single, multiple, collapsible
type: "single" (default, legacy-compatible): toggling an
item that is not open replaces the open list with just
that item's value; toggling the currently open item closes it, unless
collapsible: false, in which case that toggle is a
guard-rejected no-op (versioned: the single open item can never be
closed by re-toggling it).
With type: "multiple", toggling adds or removes the item from
the open list, preserving order (legacy-compatible). There is no
collapsible concept in multiple mode: every open item can always close.
In both modes, MOVE/HOME/END only
move focusedValue and emit FOCUS_ITEM. Header navigation never toggles a panel, unlike Tabs' automatic-activation
MOVE. Toggling is always a separate, explicit act
(Enter/Space on the focused trigger, or a
pointer click).
Anatomy
The headless layer's parts, with identical part names across every framework adapter:
Accordion.Root data-scope="accordion" data-part="root"
Accordion.Item data-part="item" (keyed by item value)
Accordion.Heading data-part="heading" (renders <h3>, legacy level, fixed)
Accordion.Trigger data-part="trigger" (button inside Heading)
Accordion.Content data-part="content" (role=region)
Item, Trigger and Content carry data-state="open" | "closed"
(open iff the item's value is in the committed open list) and
data-disabled when the item is disabled. The Heading is a
native <h3> wrapping the trigger (the legacy heading
level, fixed. Element replacement is available only through each
framework's own composition mechanism where one exists). The trigger is
a native <button type="button"> with
aria-expanded reflecting the item's open state,
aria-controls pointing at its content id, and the native
disabled attribute when disabled. Content is
role="region", aria-labelledby pointing at its
trigger id. IDs are deterministic from the part-scope seed:
scope.id("trigger", value) / scope.id("content", value).
Closed panels stay in the DOM. Content is rendered for
every item, closed or open, and closed content carries the
hidden attribute rather than being unmounted (a
versioned change from the legacy implementation, which unmounted
closed panels entirely). The hidden key is always
emitted (present or explicitly cleared), matching Tabs' own
always-emit rule. Hidden elements still stay out of the accessibility
tree, so getByRole("region") only ever sees the open panel(s). The DOM presence still means aria-controls /
aria-labelledby always resolve, and consumers get a real
height-collapse seam to animate against. Height-animation itself stays
a styled/consumer concern; there is no measured-height machinery in
the headless layer.
Keyboard
No roving tabindex: every trigger is a native button in normal
document tab order (tabIndex unset on all of them), so all
triggers are always individually tabbable. This is the one structural
difference from Tabs/Select's roving-tabindex pattern, and it is
legacy-compatible: header arrow-key navigation is a purely versioned
addition layered on top, resolved through the machine's
MOVE/HOME/END →
FOCUS_ITEM command, and it never toggles anything.
Enter / SpaceArrowDown / ArrowUpHome / EndTab / Shift+Tab- Keys
ArrowDown / ArrowUp- When
- any trigger, focused
- Effect
- Moves focus to the next/previous enabled trigger, wrapping past either end; never toggles (versioned: MOVE → FOCUS_ITEM, header navigation only).
- Keys
Home / End- When
- any trigger, focused
- Effect
- Moves focus to the first/last enabled trigger; never toggles (versioned).
- Keys
Tab / Shift+Tab- When
- any orientation
- Effect
- Every trigger is a native button in normal document tab order, so there is NO roving tabindex, unlike Tabs/Select (legacy-compatible).
Accessibility
Disabled items are native disabled buttons, so no click or focus reaches them, but they remain rendered and perceivable, excluded only
from header arrow-key navigation. Clicking a trigger toggles with
reason "pointer"; keyboard-synthesised clicks (
detail === 0) report "keyboard", the same
Tabs rule. connectAccordion and every prop getter are
callable with no DOM present. Part-scope registration happens on
mount, and IDs are deterministic per scope seed, so server-rendered
markup and the first client render agree.
Evidence & further reading
-
accordion.contract.ts, the shared browser-contract suite, implemented per adapter in each framework's ownbrowser/accordion.spec.ts(React, Vue, Solid, Svelte, Angular) plus the core vanilla mount. 174 runs green. -
Build your own component on
@grassroot/ui-headless-core, the same machine → part scope → connect → commands recipe this page walks through for Accordion, applied to a component that isn't one of the shipped machines. -
accordion.spec.md, the reviewable anatomy/ARIA/keyboard/focus/forms/SSR contract; code must match it, and where they disagree the spec wins. - Tabs: the four-layer walkthrough, the closest sibling machine: same
focusedValue+FOCUS_ITEMheader-navigation pattern, but Accordion's committed value is an array and its MOVE/HOME/END never select. - Select: the four-layer walkthrough, the same documentation contract, worked through Select's own anatomy.
- Switch: three-layer walkthrough, the platform's smallest teaching example, one layer currently real.