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

ts
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 item
changes.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. Uncontrolled TOGGLE commits and emits one VALUE_CHANGE_REQUEST with the committed open list and reason; controlled variants leave the authoritative value unchanged (still track focusedValue) and emit the request with the proposed list. Flipping controlled ↔ uncontrolled after start produces a binding-mode-change diagnostic, 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) and FOCUS_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 emits FOCUS_ITEM; it never toggles.
  • Forms: none.
  • Disabled items: guarded by TOGGLE/ MOVE/HOME/END/FOCUS in the machine itself. The legacy per-adapter disabled check is deleted. The headless trigger is a native disabled button, 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/ENDFOCUS_ITEM command, and it never toggles anything.

Keys
When
Effect
Enter / Space
any trigger, focused
Toggles that item (native button activation, legacy-compatible).
ArrowDown / ArrowUp
any trigger, focused
Moves focus to the next/previous enabled trigger, wrapping past either end; never toggles (versioned: MOVE → FOCUS_ITEM, header navigation only).
Home / End
any trigger, focused
Moves focus to the first/last enabled trigger; never toggles (versioned).
Tab / Shift+Tab
any orientation
Every trigger is a native button in normal document tab order, so there is NO roving tabindex, unlike Tabs/Select (legacy-compatible).
Keys
Enter / Space
When
any trigger, focused
Effect
Toggles that item (native button activation, legacy-compatible).
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 own browser/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_ITEM header-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.