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

indeterminatecheckedunchecked

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.

Layer
Package
Ships behaviour
Machine
@grassroot/ui-machines/checkbox
Tri-state checked binding, disabled/read-only guards, no DOM.
Headless
@grassroot/ui-headless-{react,vue,solid,svelte,angular}/checkbox
Native checkbox, ARIA, forms, data attributes, no visual recipe.
Vanilla
@grassroot/ui-headless-core/checkbox (mountCheckbox)
The same headless contract with no framework runtime at all.
Styled
@grassroot/ui-{react,vue,solid,svelte,angular}
Grassroot's box/check visual recipe, tokens and classes only.
Layer
Machine
Package
@grassroot/ui-machines/checkbox
Ships behaviour
Tri-state checked binding, disabled/read-only guards, no DOM.
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. checked and defaultChecked both accept boolean | "indeterminate". There is no second indeterminate prop. 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, never false, matching the native property's own behaviour, where toggling a mixed checkbox checks it. A user action (pointer, keyboard, or a programmatic TOGGLE) never proposes "indeterminate"; the command's checked is always a plain boolean. Only the consumer can put a checkbox back into the indeterminate state, and only through the binding.
  • Uncontrolled TOGGLE commits the proposed boolean and emits exactly one CHECKED_CHANGE_REQUEST with the committed value and the event's reason.
  • Controlled TOGGLE leaves 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 TOGGLE a true no-op: same state reference, no command, no version bump. The guard applies to programmatic events too.
  • RESET restores uncontrolled defaultChecked (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.

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
indeterminate
Proposes true and clears the live indeterminate property, and never proposes false.
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
indeterminate
Effect
Proposes true and clears the live indeterminate property, and never proposes false.
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