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
| prop | type | default | |
|---|---|---|---|
indeterminate? | boolean | false | the third state, neither on nor off |
checked? | boolean | — | controlled, alongside onChange |
defaultChecked? | boolean | — | uncontrolled, read by the form |
disabled? | boolean | false | native, so it also leaves the tab order |
…rest | props of input | — | the 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.
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.
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.
<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-*.
<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.
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.
.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.