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

ts
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-activation MOVE/HOME/END) commits and emits one VALUE_CHANGE_REQUEST with the committed value and reason; controlled variants leave the authoritative value unchanged (still track focusedValue) and emit the request with the proposed value. Flipping controlled ↔ uncontrolled after start produces a binding-mode-change diagnostic 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) and FOCUS_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 RESET event (exposed for programmatic use, not wired to native form reset).
  • Disabled tabs: skipped by MOVE/ HOME/END; the headless trigger is a native disabled button, so no click or focus reaches it. The machine's own SELECT/FOCUS guards 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.

Keys
When
Effect
ArrowRight / ArrowLeft
horizontal orientation, automatic activation
Moves to the next/previous enabled tab, wrapping past either end, and selects it immediately (legacy-compatible).
ArrowDown / ArrowUp
vertical orientation (orientation="vertical")
Bound instead of ArrowRight/ArrowLeft; the unbound axis is not handled (versioned).
ArrowRight / ArrowLeft, manual activation
activation="manual"
Moves focus only (FOCUS_ITEM, no selection); Enter/Space select the focused tab (versioned).
Home / End
any orientation
Moves to the first/last enabled tab (versioned).
Enter / Space
any orientation
Selects the focused tab (no-op if it is already committed, legacy parity).
dir="rtl"
horizontal orientation
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).
loop: false input
any orientation
MOVE clamps at either end instead of wrapping (versioned; the default loop: true wraps like legacy).
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).
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