Components
Checkbox
The classic pick-one-or-many control. A sibling of Selector, not Button: a selection or acknowledgment, usually several at a time and often saved on submit. Unchecked is a plain box with a quiet border; checked fills in solid gold with a white check — friendly and unmistakable. Built on a real <input type="checkbox">, so Space toggles it, it submits with the form, and it supports the indeterminate (mixed) state natively.
Checkbox is the bare control. For a boolean form value — a labelled row with helper / error and auto-linked ARIA — reach for CheckboxField. Reach for <Switch> instead when toggling acts immediately: a checkbox selects or acknowledges (often deferred to a submit), a switch turns something on right now.
States
Unchecked is a flat box with a quiet border; checked fills in gold and draws a white check; indeterminate swaps the check for a dash (the parent-of-partial mark, below); disabled dims the whole control. Hover deepens the border; focus brings the gold ring. The glyph always carries the state alongside the color — check, dash, or empty — so it reads for color-blind users too (ADR-0017).
Indeterminate — the select-all parent
The one place indeterminate belongs: a parent that summarises its children. None checked → parent unchecked; some checked → parent indeterminate (the dash); all checked → parent checked. Toggling the parent checks or clears every child. It is a derived display state, never a third value the user picks.
In a form
Inside a form, reach for CheckboxField — the form-bound compound. It reads the field from TanStack Form context, binds checked / onCheckedChange to the boolean value, marks the field touched on blur, and wires the label / helper / error ARIA. The box sits beside the label as a row (top-aligned so a wrapping label keeps the box on its first line); the label is a real <label>, so clicking the text toggles the box. The classic case is a required "I accept the terms".
Materiality
The spec values below document the proposed system's ink-on-glass treatment. The official checkbox plays it differently: flat and opaque, a quiet border when empty, a solid gold fill with a white check when checked. Color does the talking here — it usually does.
- box
--space-4(16px) square,--radius-smcorners.A small flat square on an opaque surface — nothing shows through. Same small radius as inputs, not the pill of a switch.
- frame
--space-pxhairline in--border-hairlineat rest, darkening to--fg-1when checked; hover lifts it to--fg-2The frame-does-everything trick belongs to the proposed system. Officially the border stays quiet and the fill takes over: a checked box goes solid gold — no squinting required.
- glyph
- Check + dash in
--fg-1ink,--space-3(12px), a bolder 3px stroke.The check (selected) and dash (indeterminate) share a center; only one shows at a time. In the official system they draw in white on the gold fill — a bold stroke keeps them legible at 12px.
- gold fill
- The signature.
The proposed system never fills a checked box — that restraint lives over there. The official system goes the other way on purpose: checked means a solid gold fill with a white check, the same warm, direct signal you see across the portal.
- focus ring
--focus-ringon :focus-visible.Keyboard focus lands the same gold ring the rest of the system uses; pointer clicks don't.
Props
- checked: boolean
- Controlled checked state. Required — Checkbox has no uncontrolled mode; binary state should live somewhere nameable.
- onCheckedChange: (checked: boolean) => void
- Fired with the next state when the user toggles. Required.
- indeterminate?: boolean
- Shows the dash and sets the native + ARIA mixed state. A display state for a select-all parent — not a value the user toggles to.
- disabled?: boolean
- Dims the control to 50% and blocks the toggle.
- aria-label, id, onBlur, name, …
- Standard input attributes pass through to the real checkbox. Standalone, give it an accessible name via
aria-labelor a wrapping<label>.type,checked, and the mixed state are managed internally. - ref: Ref<HTMLInputElement>
- Forwarded to the underlying input (the indeterminate property is set on it for you).
Usage
import { Checkbox } from '@halo-compliance/ui'
<Checkbox
checked={selected}
onCheckedChange={setSelected}
aria-label="Select this row"
/>const all = kids.every(Boolean)
const some = kids.some(Boolean) && !all
<Checkbox
checked={all}
indeterminate={some}
onCheckedChange={() => setKids(all ? allFalse : allTrue)}
aria-label="All notifications"
/>import { useHaloForm } from '@halo-compliance/ui'
const form = useHaloForm({
defaultValues: { acceptedTerms: false },
})
<form.AppField name="acceptedTerms">
{(field) => (
<field.CheckboxField label="I accept the terms" />
)}
</form.AppField>When to use
- A selection you may revisit before committing — items in a list, filters, options that apply on submit rather than instantly.
- An acknowledgment — "I accept the terms", "I understand", a required consent gate.
- Several independent on/off choices together — where a column of switches would read as a wall of live toggles, a checklist is easy to scan.
Antipatterns
- An instant-apply toggle. If flipping it acts immediately (a setting, a feature flag), that's a Switch — switches mean "this happens right now".
- One of several mutually exclusive options. Pick-one is a radio or single Selector; checkboxes are for independent, multi-select choices.
- Indeterminate as a user choice. The dash is a derived parent state, not a third value someone clicks to. Clicking always resolves to checked or unchecked.
- A nameless box. Always pair it with a label (a wrapping one is clickable) or pass aria-label — or use CheckboxField.
Related
- /switch — the live / immediate sibling: flip it and it happens right away.
- /selector — pick-one or searchable multi-select when a list of boxes is too many.
- /input — the other everyday form control, and the TextField sibling of CheckboxField.
- /tokens —
--space-4,--radius-sm, and the focus-ring / duration tokens.