Docs Platform

Switch

The platform's smallest teaching example, and now a fully shipped four-layer component like the rest of them. The machine decides the checked state and which transitions are allowed, the headless layer turns that into a native checkbox carrying the ARIA, form and data attributes it needs, and the styled layer adds tokens and classes. That is all there is to it.

Live machine

The shipped switchMachine running through createService — two states, one event, as the workbench catalogue describes it. The switch, the state strip, and the transition list all read the same live snapshot.

switch · live via createService

States · walked from the machine

checkedunchecked

Transitions from here · click to send

Context

{
  "mode": "uncontrolled"
}

Log · newest first

No events yet — use the component above or send a transition.

Explore the layers

Machine (@grassroot/ui-machines/switch) is pure state: no DOM, no framework, no styling. 67 versioned tests. Headless (@grassroot/ui-headless-<framework>/switch) adds a native <input type="checkbox" role="switch"> 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, with every framework's legacy native-checkbox implementation deleted in favor of it. Vanilla (mountSwitch, from @grassroot/ui-headless-core/switch) is the framework-free mount helper. The shared browser contract (packages/ui-headless/browser-contract/switch.contract.ts) runs its 22 tests against all six mount paths (vanilla plus the five framework headless adapters) across three browser engines.

Layer
Package
Ships behaviour
Machine
@grassroot/ui-machines/switch
Checked binding, disabled/read-only guards, no DOM.
Headless
@grassroot/ui-headless-{react,vue,solid,svelte,angular}/switch
Native checkbox, ARIA, forms, data attributes, no visual recipe.
Vanilla
@grassroot/ui-headless-core/switch (mountSwitch)
The same headless contract with no framework runtime at all.
Styled
@grassroot/ui-{react,vue,solid,svelte,angular}
Grassroot's crimson-track/mechanical-knob visual recipe, tokens and classes only.
Layer
Machine
Package
@grassroot/ui-machines/switch
Ships behaviour
Checked binding, disabled/read-only guards, no DOM.
Layer
Headless
Package
@grassroot/ui-headless-{react,vue,solid,svelte,angular}/switch
Ships behaviour
Native checkbox, ARIA, forms, data attributes, no visual recipe.
Layer
Vanilla
Package
@grassroot/ui-headless-core/switch (mountSwitch)
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 crimson-track/mechanical-knob 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.

SwitchInput:   checked? / defaultChecked?   (ONE state-backed binding)
               disabled? / readOnly?
StateValue:    "checked" | "unchecked"
Context:       { mode: "controlled" | "uncontrolled" }
Events:        PRESS { reason: "pointer" | "keyboard" | "programmatic" }
               RESET
Command:       CHECKED_CHANGE_REQUEST { checked, reason }
  • Binding mode: input.checked === undefined ? "uncontrolled" : "controlled", recomputable by idle setInput() and fixed at first start(). A post-start mode flip returns state unchanged plus one binding-mode-change diagnostic, the same rule every other layered component follows.
  • Uncontrolled PRESS commits the toggled value and emits exactly one CHECKED_CHANGE_REQUEST with the committed value and the event's reason.
  • Controlled PRESS leaves value and context references untouched and emits exactly one request with the proposed value, which is a command-only transition. Repeated presses keep proposing the same value, never drifting locally.
  • Disabled or read-only input makes PRESS a true no-op: same state reference, no command, no version bump. The guard applies to programmatic events too.
  • RESET restores uncontrolled defaultChecked with no command; controlled state is always a true no-op.

Anatomy

The headless layer's parts, with identical part names across every framework adapter, the resolved 17-row register cited as [baseline #n]:

Switch.Root          data-scope="switch" data-part="root"    (native <label>)
Switch.RootProvider  service/context provider for explicit composition
Switch.HiddenInput   native <input type="checkbox" role="switch">
Switch.Control       data-part="control"       (aria-hidden decoration)
Switch.Thumb         data-part="thumb"         (aria-hidden decoration)
Switch.Label         data-part="label"
Switch.Description   data-part="description"
Switch.Context / useSwitchContext

The root stays a native <label> [baseline #16] so implicit label activation survives alongside the explicit ARIA relationships below. Every rendered part carries data-scope="switch", data-part and data-state="checked" | "unchecked". Root, hidden input, control and thumb additionally carry data-disabled (present-empty-string when disabled) and data-readonly; Label and Description carry state only.

No data-focus-visible, a recorded deviation from the platform plan's illustrative DOM contract. Focus-visible is expressible in pure CSS from the real focused :focus-visible input (sibling/:has() selectors, exactly what the styled layer already does); modelling it as machine state would add a focus region to a frozen machine for zero behavioural gain.

The hidden input is a real native checkbox with role="switch", never replaced by a button. Accessible name comes from Switch.Label via aria-labelledby, or from ariaLabel; aria-labelledby/ aria-describedby are emitted only when that part is actually registered in the scope [baseline #14], the same mechanism Dialog's title/description uses, so an unregistered part never leaves a dangling IDREF for axe to flag. A consumer id seeds the part scope [baseline #10], so the hidden input's, Label's and Description's ids are all deterministic from it.

Root vs RootProvider, and why both exist

Switch.Root creates the service/scope/connector and always injects Switch.HiddenInput at a fixed position alongside its children, which is the convenient default. Switch.RootProvider is the advanced, explicit-part composition entry point: it renders only the root <label> and its children, and does not render the hidden input. The consumer places <Switch.HiddenInput/> themselves, wherever the markup needs it.

This matters concretely because the styled layer's CSS uses Tailwind's peer-checked:/peer-disabled:/peer-focus-visible: variants, which compile to a plain CSS general-sibling combinator. The hidden input and the track/knob spans must be literal DOM siblings under one shared, positioned parent, exactly how the legacy markup nested input+track+knob inside one wrapper span. Switch.Root's fixed hidden-input position would make that sibling grouping impossible, so every styled adapter composes via useSwitch()/SwitchRootProvider instead, placing HiddenInput/Control/ Thumb together as literal siblings inside its own toggle wrapper, with every part still going through the same mergeProps/protected-keys/ref-composition machinery Switch.Root would give it. Nothing in the styled layer hand-rolls prop-bag merging.

Six mount paths

The shared browser contract runs its 22 tests against six real mount paths, across three engines: the framework-free mountSwitch vanilla mount, plus the React, Vue, Solid, Svelte and Angular headless adapters. All six apply the same connector prop bags, so no adapter reimplements ARIA, keyboard or form logic locally.

All five styled adapters were rewritten onto this headless layer with their legacy native-checkbox bindings deleted, so there is no longer a separate "baseline" styled Switch. Angular gained the checkedChange output it previously lacked (enabling [(checked)]); every framework's primary callback is now onCheckedChange (Vue emits checked-change) carrying exactly { checked, reason }. No adapter infers a callback by diffing snapshots.

Keyboard, pointer, focus

The native checkbox is the single activation path. There is no custom key handler and no synthetic second PRESS when the label or control is clicked.

Trigger
When
Effect
Space
hidden input focused
Browser checkbox semantics toggle the value; classified reason: "keyboard".
Pointer click
label, control decoration, or input itself
Toggles exactly once, classified reason: "pointer". The root <label> passes the click through to its native input rather than re-firing it.
Any activation
read-only
Prevented before the browser toggles the property, and the property is restored afterwards; the machine guard remains the final protection for direct service events.
Trigger
Space
When
hidden input focused
Effect
Browser checkbox semantics toggle the value; classified reason: "keyboard".
Trigger
Pointer click
When
label, control decoration, or input itself
Effect
Toggles exactly once, classified reason: "pointer". The root <label> passes the click through to its native input rather than re-firing it.
Trigger
Any activation
When
read-only
Effect
Prevented before the browser toggles the property, and the property is restored afterwards; the machine guard remains the final protection for direct service events.

Controlled PRESS never commits. The connector restores the native checked property from the snapshot synchronously inside the change handler [baseline #4], so a controlled switch never displays the browser-toggled property while waiting for a framework render. Focus is never moved by the component. RTL: no directional behaviour, because the switch has no inline-axis semantics; direction is a styling concern.

Forms

name, value, required and disabled land on the native input, so submission, validation and autofill are the browser's, and the connector never emits a synthetic value="on" [baseline #15]. A form reset sends RESET: uncontrolled state returns to defaultChecked with no change request; controlled state stays input-authoritative. The mount helper attaches its reset listener to the hidden input's owning form element directly, because the native reset event fires at the form and does not pass through a descendant label, so root props alone could never observe it.

Accessibility

role="switch" on a real native checkbox, with aria-checked derived from the snapshot and never mutated in a handler [baseline #12]. The legacy setAttribute hack is gone. aria-readonly="true" is emitted when read-only [baseline #5]. In development only, a switch with no registered Label part, no ariaLabel option and no consumer aria-label/aria-labelledby reports one console.error, never a throw [baseline #17]. connectSwitch and every prop getter are callable with no DOM present; ids are deterministic per scope seed, so server-rendered markup and the first client render agree, and no timers or side effects exist until a real user event fires.

Evidence & further reading