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).

state: unchecked
Default. A flat box with a quiet border, no glyph.
state: checked
Fills in solid gold and draws a white check.
state: indeterminate
A dash, not a check — the "some but not all" mark for a parent of partially-selected children. Set via the indeterminate prop, never by the user directly.
state: disabled
Dimmed to 50% with a not-allowed cursor; holds its checked value.
state: focus
Gold focus ring via :focus-visible on the underlying input. Shown statically here through the is-focused modifier; tab to a live box for the real ring.

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".

A neutral hint sits here; a validation error would replace it.
Bound value: unchecked

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-sm corners.

A small flat square on an opaque surface — nothing shows through. Same small radius as inputs, not the pill of a switch.

frame
--space-px hairline in --border-hairline at rest, darkening to --fg-1 when checked; hover lifts it to --fg-2

The 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-1 ink, --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-ring on :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-label or 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

Standalone — controlled, with an accessible nametsx
import { Checkbox } from '@halo-compliance/ui'

<Checkbox
  checked={selected}
  onCheckedChange={setSelected}
  aria-label="Select this row"
/>
Select-all parent — derived indeterminatetsx
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"
/>
Form-bound — CheckboxField inside useHaloFormtsx
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.
  • /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.