- Layer
- Machine
- Package
@grassroot/ui-machines/switch- Ships behaviour
- Checked binding, disabled/read-only guards, no DOM.
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
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.
@grassroot/ui-machines/switch@grassroot/ui-headless-{react,vue,solid,svelte,angular}/switch@grassroot/ui-headless-core/switch (mountSwitch)@grassroot/ui-{react,vue,solid,svelte,angular}- 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 idlesetInput()and fixed at firststart(). A post-start mode flip returns state unchanged plus onebinding-mode-changediagnostic, the same rule every other layered component follows. -
Uncontrolled
PRESScommits the toggled value and emits exactly oneCHECKED_CHANGE_REQUESTwith the committed value and the event's reason. -
Controlled
PRESSleaves 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
PRESSa true no-op: same state reference, no command, no version bump. The guard applies to programmatic events too. -
RESETrestores uncontrolleddefaultCheckedwith 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.
Spacereason: "keyboard".reason: "pointer". The root <label> passes the click through to its native input rather than re-firing it.- 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
-
switch.contract.ts, the shared browser-contract suite (22 tests), implemented per adapter in each framework's ownbrowser/switch.spec.ts(React, Vue, Solid, Svelte, Angular) plus the core vanilla mount, including axe checks for "no violations while unchecked", "no violations while checked", and "no violations for the bare, disabled and read-only scenarios". -
switch.spec.md, the reviewable anatomy/ARIA/keyboard/focus/forms/SSR contract; code must match it, and where they disagree the spec wins. -
switch-baseline.md, the 17-row differences register across the five legacy styled adapters, resolved 2026-08-10 and cited throughout this page as[baseline #n]. -
Build your own component on
@grassroot/ui-headless-core, the same machine → part scope → connect → commands recipe this page walks through for Switch, applied to a component that isn't one of the shipped machines. - DropdownMenu: the four-layer walkthrough
- Dialog: the four-layer walkthrough
- Tooltip: the four-layer walkthrough
- Accordion: the four-layer walkthrough
- Select: the four-layer walkthrough