- Layer
- Machine
- Package
@grassroot/ui-machines/checkbox- Ships behaviour
- Tri-state checked binding, disabled/read-only guards, no DOM.
Docs Platform
Checkbox
Switch with a third state bolted on. The machine decides checked, unchecked or indeterminate and which transitions between them are allowed. The headless layer turns that into a plain native checkbox carrying the ARIA, form and data attributes it needs, and the styled layer adds tokens and classes. That is the whole component.
Live machine
The shipped checkboxMachine, seeded indeterminate — three checked values and one TOGGLE that cycles them.
checkbox · 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/checkbox) is pure state: no DOM, no framework, no styling. 106 versioned tests over
the three-value domain. Headless
(@grassroot/ui-headless-<framework>/checkbox) adds a
plain native <input type="checkbox"> 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 (mountCheckbox, from
@grassroot/ui-headless-core/checkbox) is the
framework-free mount helper. The shared browser contract
(packages/ui-headless/browser-contract/checkbox.contract.ts)
runs its 23 tests against all six mount paths (vanilla plus the five framework headless adapters) across three browser engines.
@grassroot/ui-machines/checkbox@grassroot/ui-headless-{react,vue,solid,svelte,angular}/checkbox@grassroot/ui-headless-core/checkbox (mountCheckbox)@grassroot/ui-{react,vue,solid,svelte,angular}- Layer
- Headless
- Package
@grassroot/ui-headless-{react,vue,solid,svelte,angular}/checkbox- Ships behaviour
- Native checkbox, ARIA, forms, data attributes, no visual recipe.
- Layer
- Vanilla
- Package
@grassroot/ui-headless-core/checkbox(mountCheckbox)- 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 box/check 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. Checkbox is Switch's shape plus a third state;
everything not restated on this page follows
switch.spec.md exactly.
CheckboxInput: checked? / defaultChecked? : boolean | "indeterminate" (ONE binding)
disabled? / readOnly?
StateValue: "checked" | "unchecked" | "indeterminate"
Context: { mode: "controlled" | "uncontrolled" }
Events: TOGGLE { reason: "pointer" | "keyboard" | "programmatic" }
RESET
Command: CHECKED_CHANGE_REQUEST { checked: boolean, reason }
-
Tri-state, one binding.
checkedanddefaultCheckedboth acceptboolean | "indeterminate". There is no secondindeterminateprop. The authoring normaliser supports exactly one state-backed binding today; a second binding here would need the unimplemented multi-binding gate, which is deferred to Combobox. This is a deliberate scope decision, not an oversight. -
Activating a mixed checkbox proposes
true, neverfalse, matching the native property's own behaviour, where toggling a mixed checkbox checks it. A user action (pointer, keyboard, or a programmaticTOGGLE) never proposes"indeterminate"; the command'scheckedis always a plain boolean. Only the consumer can put a checkbox back into the indeterminate state, and only through the binding. -
Uncontrolled
TOGGLEcommits the proposed boolean and emits exactly oneCHECKED_CHANGE_REQUESTwith the committed value and the event's reason. -
Controlled
TOGGLEleaves value and context references untouched and emits exactly one request with the proposed value, which is a command-only transition. Repeated activations keep proposing the same value, never drifting locally. -
Disabled or read-only input makes
TOGGLEa true no-op: same state reference, no command, no version bump. The guard applies to programmatic events too. -
RESETrestores uncontrolleddefaultChecked(which may itself be"indeterminate") with no command; controlled state is always a true no-op.
Anatomy
The headless layer's parts:
Checkbox.Root data-scope="checkbox" data-part="root" (native <label>)
Checkbox.RootProvider service/context provider for explicit composition
Checkbox.HiddenInput native <input type="checkbox"> (NO role override)
Checkbox.Control data-part="control" (aria-hidden decoration, the box)
Checkbox.Indicator data-part="indicator" (aria-hidden decoration, the check glyph)
Checkbox.Label data-part="label"
Checkbox.Description data-part="description"
Checkbox.Context / useCheckboxContext
The root stays a native <label> so implicit label
activation survives alongside the explicit ARIA relationships below.
Every rendered part carries data-scope="checkbox",
data-part and
data-state="checked" | "unchecked" | "indeterminate", three values where Switch has two. Root, hidden input, control and
indicator additionally carry data-disabled
(present-empty-string when disabled) and data-readonly;
Label and Description carry state only.
No role override and no aria-checked
outside the indeterminate state [C4]. A native checkbox
already exposes the correct role and checked state to assistive technology. Layering an explicit role/aria-checked
over it, the way Switch must for role="switch", would be
redundant and a known screen-reader hazard. The one exception is the
indeterminate state itself, where no native ARIA equivalent exists:
there the connector emits aria-checked="mixed", and
nowhere else.
No data-focus-visible on Control or
Indicator, the same recorded deviation Switch carries, inherited
rather than re-decided: 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, never replaced by a button, never given a role override. Accessible name comes from
Checkbox.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], 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
Checkbox.Root creates the service/scope/connector and
always injects Checkbox.HiddenInput at a fixed position
alongside its children, which is the convenient default.
Checkbox.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
<Checkbox.HiddenInput/> themselves, wherever the
markup needs it.
This matters concretely because the frozen checkboxClasses
CSS uses Tailwind's peer-checked:/peer-focus-visible:
variants, which compile to a plain CSS general-sibling combinator. The
native input must remain a literal DOM sibling immediately preceding
the box <span>, under one shared, positioned parent, exactly how the legacy markup nested
input+span.box directly inside one wrapping
<label>. Checkbox.Root's fixed
hidden-input position would make that sibling grouping impossible, so
every styled adapter composes via
useCheckbox()/CheckboxRootProvider instead,
placing HiddenInput/Control/Indicator
together as literal siblings inside its own root, with every part
still going through the same
mergeProps/protected-keys/ref-composition machinery
Checkbox.Root would give it. Nothing in the styled layer
hand-rolls prop-bag merging.
Six mount paths
The shared browser contract runs its 23 tests against six real mount
paths, across three engines: the framework-free
mountCheckbox 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 Checkbox. Every framework's
checked/defaultChecked now accepts
boolean | "indeterminate" [C1]; every framework's primary
callback is onCheckedChange carrying exactly
{ checked, reason }. No adapter infers a callback by
diffing snapshots. The legacy visual part named box maps
onto Control, and the check-glyph child maps onto
Indicator; the frozen visual recipe itself is unchanged,
including for indeterminate, which has no dedicated look. Only the ARIA/DOM-property plumbing is new.
Keyboard, pointer, focus
The native checkbox is the single activation path. There is no custom key handler and no synthetic second TOGGLE 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.true and clears the live indeterminate property, and never proposes false.- 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
- indeterminate
- Effect
- Proposes
trueand clears the liveindeterminateproperty, and never proposesfalse.
- 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 TOGGLE never commits. The connector restores
the native checked and indeterminate
DOM properties from the snapshot synchronously inside the change
handler, via one shared restoreNativeCheckboxState()
helper, so a controlled checkbox never displays the browser-toggled
property (or loses a mixed state the browser itself just cleared)
while waiting for a framework render. Focus is never moved by the
component. RTL: no directional behaviour, because the checkbox 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, inherited]. A form
reset sends RESET: uncontrolled state returns to
defaultChecked (indeterminate included) 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
No role override and no aria-checked outside
the indeterminate state [C4]. A native checkbox already exposes the
right role and checked state, and the legacy adapters already emitted
no explicit ARIA at all, so this is a continuation of the existing
posture rather than a new abstention. aria-checked="mixed"
is emitted only in the indeterminate state, driven from the snapshot
and never mutated in a handler. aria-readonly="true" is
emitted when read-only [baseline #5, inherited]. In development only,
a checkbox with no registered Label part, no
ariaLabel option and no consumer
aria-label/aria-labelledby reports one
console.error, never a throw [baseline #17, inherited].
connectCheckbox 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, though the live
indeterminate property itself cannot be server-rendered
and is only ever set once the client mounts. No timers or side effects
exist until a real user event fires.
Evidence & further reading
-
checkbox.contract.ts, the shared browser-contract suite (23 tests), implemented per adapter in each framework's ownbrowser/checkbox.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 indeterminate, bare, disabled and read-only scenarios". -
checkbox.spec.md, the reviewable anatomy/ARIA/keyboard/focus/forms/SSR contract; code must match it, and where they disagree the spec wins. It defers everything not restated toswitch.spec.mdverbatim, since Checkbox is Switch's shape plus a third state. -
checkbox-radio-baseline.md, the resolved register across the five legacy styled Checkbox and Radio adapters, captured 2026-08-10. Twelve rows (1-11, 14) inherit the Switch register's resolutions by reference rather than re-deciding them; rows 16 and 17 carry over unchanged as well; C1-C4 are the decisions new to this gate, cited throughout this page as[Cn]. -
Build your own component on
@grassroot/ui-headless-core, the same machine → part scope → connect → commands recipe this page walks through for Checkbox, applied to a component that isn't one of the shipped machines. - Switch: the four-layer walkthrough
- 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