- Keys
ArrowRight / ArrowLeft- When
- horizontal orientation, automatic activation
- Effect
- Moves to the next/previous enabled tab, wrapping past either end, and selects it immediately (legacy-compatible).
Docs Platform
Tabs
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 { tabsMachine, tabsSelectors, type TabsCommand } from "@grassroot/ui-machines/tabs" const tabs = [ { value: "profile", disabled: false }, { value: "settings", disabled: false }, { value: "billing", disabled: true },] const changes: TabsCommand[] = []const service = createService(tabsMachine, { input: { tabs, defaultValue: "profile", activation: "automatic", loop: true }, runCommand: (change) => { changes.push(change) },}) service.start()service.send({ type: "MOVE", direction: 1 }) // focus + select "settings"service.send({ type: "SELECT", value: "profile", reason: "keyboard" }) tabsSelectors.value(service.getSnapshot()) // "profile"changes.at(-1) // VALUE_CHANGE_REQUEST for "profile", 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:
SELECT{ value, reason },MOVE{ direction: -1 | 1 },HOME,END,FOCUS{ value },BLUR,RESET. -
Value binding: uncontrolled
SELECT(and automatic-activationMOVE/HOME/END) commits and emits oneVALUE_CHANGE_REQUESTwith the committed value and reason; controlled variants leave the authoritative value unchanged (still trackfocusedValue) and emit the request with the proposed value. Flipping controlled ↔ uncontrolled after start produces abinding-mode-changediagnostic rather than silently changing behaviour, the same S12 contract Select follows. -
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 selection) andFOCUS_ITEM{ key: value } (resolved through the part scope, silent no-op when unmounted; the roving-tabindex focus target). -
Forms: none. Tabs has no form value, no hidden input,
no reset participation beyond the machine's own
RESETevent (exposed for programmatic use, not wired to native form reset). -
Disabled tabs: skipped by
MOVE/HOME/END; the headless trigger is a nativedisabledbutton, so no click or focus reaches it. The machine's ownSELECT/FOCUSguards stay authoritative for direct service events regardless.
Tabs travel in machine input, not event payloads: value /
defaultValue pick controlled vs. uncontrolled mode at first
start, activation picks automatic vs. manual selection on
move, and loop opts into wrap-around MOVE
(default true, for legacy parity).
Activation modes
activation: "automatic" (default, legacy-compatible): moving
focus with MOVE/HOME/END selects
the newly focused tab immediately, emitting one
VALUE_CHANGE_REQUEST with reason "keyboard".
This matches the legacy reduceTabs reducer's observable
behaviour exactly under default input.
activation: "manual" (versioned, opt-in): moving focus only
updates focusedValue and emits FOCUS_ITEM;
nothing is selected until Enter/Space (or a
direct SELECT) commits the focused tab. Selecting the
already-committed value is a no-op in both modes (same state reference, no command), matching legacy reduceTabs parity.
Anatomy
The headless layer's parts, with identical part names across every framework adapter:
Tabs.Root data-scope="tabs" data-part="root"
Tabs.List data-part="list" (role=tablist)
Tabs.Trigger data-part="trigger" (keyed by tab value; role=tab)
Tabs.Content data-part="content" (keyed by tab value; role=tabpanel)
Every part carries data-scope="tabs" and its
data-part. Root and List carry
data-orientation="horizontal" | "vertical". Trigger and
Content carry data-state="active" | "inactive" (active iff
the part's value is the committed value) and data-disabled
when the tab is disabled. The trigger is a native
<button type="button"> with role="tab",
aria-selected, aria-controls pointing at its
content id, and the native disabled attribute when
disabled (legacy native semantics preserved). The list is
role="tablist" with aria-orientation="vertical"
when vertical (absent when horizontal, which is the ARIA default). Content is
role="tabpanel", aria-labelledby pointing at
its trigger id, tabindex="0" (panels are reachable even
when they contain no focusable child), and inactive content carries the
hidden attribute. IDs are deterministic from the part-scope seed: scope.id("trigger", value) /
scope.id("content", value).
Keyboard
Roving tabindex: the focused tab (else the active tab) has
tabindex="0", all others tabindex="-1". Focus
motion is resolved through the part scope via the machine's
FOCUS_ITEM command.
ArrowRight / ArrowLeftArrowDown / ArrowUpArrowRight / ArrowLeft, manual activationHome / EndEnter / Spacedir="rtl"loop: false input- Keys
ArrowDown / ArrowUp- When
- vertical orientation (orientation="vertical")
- Effect
- Bound instead of ArrowRight/ArrowLeft; the unbound axis is not handled (versioned).
- Keys
ArrowRight / ArrowLeft, manual activation- When
- activation="manual"
- Effect
- Moves focus only (FOCUS_ITEM, no selection); Enter/Space select the focused tab (versioned).
- Keys
Home / End- When
- any orientation
- Effect
- Moves to the first/last enabled tab (versioned).
- Keys
Enter / Space- When
- any orientation
- Effect
- Selects the focused tab (no-op if it is already committed, legacy parity).
- Keys
dir="rtl"- When
- horizontal orientation
- Effect
- ArrowRight means previous and ArrowLeft means next; the connector maps physical keys to logical MOVE direction. The machine only ever sees direction: -1 | 1 (versioned).
- Keys
loop: false input- When
- any orientation
- Effect
- MOVE clamps at either end instead of wrapping (versioned; the default loop: true wraps like legacy).
Accessibility
Disabled tabs are native disabled buttons, so no click or focus reaches them, but they remain rendered and perceivable, excluded only
from roving-tabindex focus. Clicking a trigger selects with reason
"pointer". connectTabs 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
-
tabs.contract.ts, the shared browser-contract suite, implemented per adapter in each framework's ownbrowser/tabs.spec.ts(React, Vue, Solid, Svelte, Angular) plus the core vanilla mount. -
Build your own component on
@grassroot/ui-headless-core, the same machine → part scope → connect → commands recipe this page walks through for Tabs, applied to a component that isn't one of the shipped machines. -
tabs.spec.md, the reviewable anatomy/ARIA/keyboard/focus/forms/SSR contract; code must match it, and where they disagree the spec wins. - Select: the four-layer walkthrough, the same documentation contract, worked through Select's own anatomy.
- Accordion: the four-layer walkthrough, the closest sibling machine: same
focusedValue+FOCUS_ITEMheader-navigation pattern, but its committed value is an array and MOVE/HOME/END never select. - Switch: three-layer walkthrough, the platform's smallest teaching example, one layer currently real.