Checkbox

A native checkbox the kit only paints. The element is the control: it belongs to a form, answers the keyboard and announces itself, and CSS draws the box and the mark on top of it.

API

Props

proptypedefault
indeterminate?booleanfalsethe third state, neither on nor off
checked?booleancontrolled, alongside onChange
defaultChecked?booleanuncontrolled, read by the form
disabled?booleanfalsenative, so it also leaves the tab order
…restprops of inputthe type is owned by the component

Examples

Usage

Controlled like any input: you hold the value, onChange hands you the new one. The caption is a Label pointing at it, which is what makes the word clickable — nothing in the component listens for that.

tsx
const [checked, setChecked] = useState(true);

<Checkbox id="terms" checked={checked} onChange={(event) => setChecked(event.target.checked)} />
<Label as="label" htmlFor="terms">accept the terms</Label>

A group and its parent

indeterminate is the state a parent needs when some of its children are on: not checked, not empty, and never something the user sets — you compute it from the selection.

tsx
const [selected, setSelected] = useState<readonly string[]>(["invoices"]);

const toggle = (option: string) =>
  setSelected((current) =>
    current.includes(option) ? current.filter((item) => item !== option) : [...current, option],
  );

<Checkbox
  checked={selected.length === options.length}
  indeterminate={selected.length > 0 && selected.length < options.length}
  onChange={(event) => setSelected(event.target.checked ? options : [])}
/>

<List
  array={options}
  itemExtractor={({ row }) => (
    <Checkbox key={row} checked={selected.includes(row)} onChange={() => toggle(row)} />
  )}
/>

States

Empty, checked, indeterminate, and the same two disabled. Disabled is the native attribute, so the control also leaves the tab order and stops answering the keyboard — the kit only dims it.

tsx
<Checkbox defaultChecked={false} />
<Checkbox defaultChecked />
<Checkbox indeterminate />
<Checkbox defaultChecked disabled />
<Checkbox disabled />

Details

The element is the control

Underneath there is an input[type="checkbox"] with appearance: none, and everything that follows from being one comes for free: the space bar toggles it, a label pointing at it toggles it too, a form serialises it under its name, and a screen reader announces it without the kit declaring a single role or aria-*.

tsx
<form onSubmit={handleSubmit}>
  <Checkbox name="newsletter" value="weekly" defaultChecked />
  <Button as="button" type="submit">
    <Button.Label label="save" />
  </Button>
</form>

One line of JavaScript

indeterminate is the exception, and the only reason this component has an effect at all: it is a property of the DOM node, not an attribute, so it cannot be written in JSX. The component keeps a ref and mirrors the prop onto the node when it changes.

tsx
useEffect(() => {
  if (checkboxRef.current) checkboxRef.current.indeterminate = indeterminate;
}, [indeterminate]);

The mark is a pseudo-element

There is no icon and no child: the tick is the ::before of the input, filled with currentColor and cut to shape with a clip-path. The indeterminate state reuses the same box with a different shape, which is why the two never disagree about size or colour.

css
.margo-checkbox::before {
  background-color: currentColor;
  clip-path: polygon(14% 44%, 0 60%, 39% 100%, 100% 16%, 85% 0, 39% 69%);
}

.margo-checkbox:indeterminate::before {
  clip-path: inset(44% 12% 44% 12% round 1px);
}

The mark enters with scale and opacity — the two properties the compositor can animate — and the box itself only reacts to the press, with the same shrink an Item has. Colours change without a transition, on purpose: a checkbox has to look decided.

It reads like a button

The states are the ones Button uses, so a form made of both is legible without learning two languages: the border strengthens on hover, turns primary on focus-visible with the same outline, and checked is that same green border rather than a filled square. At this size the border is the whole signal — a shadow would only blur it.

Hover is scoped to enabled, because a disabled input still matches :hover in the browser and would otherwise light up under a pointer that cannot use it.