Components/Form
Checkbox
Checkboxes let a reader choose any number of options from a list, including none of them. Each box answers its own question independently, so ticking one says nothing about the rest. A single box on its own is also how a yes-or-no answer is collected when it travels with a form.
import { Checkbox } from '@growth-protocol-ai/alpha';When to use
Reach for a checkbox when each option stands on its own and the reader may tick as many of them as they like, or none at all — a list of interests, a set of filters, one term to agree to. Give every box text it can be clicked on: an unlabelled box leaves the reader guessing what ticking it commits them to, and leaves a screen reader with nothing to read out but the word checkbox.
When not to use
Do not use checkboxes where exactly one answer is allowed. A radio group makes that rule visible before the reader tries to break it, where a row of boxes invites a second tick and then has to take it back. A toggle is the other neighbour, and what separates them is when the answer lands: a toggle switches something on the moment it is pressed, while a checkbox records an answer that the form around it submits later.
icon2 values
Which mark a filled box draws, and so what ticking it means. The box, the fill and the geometry are the same either way — only the drawing inside changes, and only once there is something to draw, which is why both specimens below are ticked.
| icon | When to use |
|---|---|
check | The ordinary tick. A value is on, a row is selected, a term is agreed. |
add | A plus, for a box that puts the item into a collection rather than recording an answer about it. Reach for it in a picker, not in a form. |
| State | When to use |
|---|---|
Unchecked | The resting box: a --surface-depth-0 fill inside --border-default under the softer of the two inset shadows, and no mark at all. Nothing about it is dimmed, because an unanswered question is as available as an answered one. |
Checked | The fill becomes --button-primary, the border gives way to it, and the tighter inset shadow arrives with the mark. Through all of that the box keeps its size and its corner radius, which is what lets a column of options stay aligned however many of them are ticked. |
Mixed | A filled box carrying the bar alone, for a box that stands for others and finds them in disagreement. It outranks both the tick and the plus, because mixed is a summary rather than a third answer — and the input underneath still submits as checked or unchecked, whichever it actually is. |
Disabled | Switched off and repainted rather than faded: a --surface-disabled fill with the border kept. Any mark survives at low contrast, so a locked box still says which value it was locked holding. Skipped by the keyboard and left out of the submitted form. |
Loading | A write in flight. The box’s chrome gives way to a ring in --button-primary and the input switches off for as long as it spins, so a second change cannot set off after the first while that one is still travelling. aria-busy says the same thing aloud. |
Name every box. The label prop is the short way and puts the text inside the <label>, so a click on the words toggles; an id plus your own <label htmlFor> is the other, for a name that has to sit elsewhere in the layout. A box with neither is a control a screen reader can only call "checkbox".
Several boxes asking one question are a group, and Alpha ships the box rather than the group: wrap the set in a <fieldset> with a <legend>, which is what names it for a screen reader. A shared name does not group checkboxes the way it groups radios — it makes them one field that submits every ticked value under that key.
indeterminate is a summary, not a third answer a person can pick. It belongs on a box that stands for others, and a click on that box should resolve the split — tick them all, or clear them all — rather than cycle back into mixed. Nothing in the component enforces that, because nothing in the component knows what the box summarises.
While a write is in flight, leave the box showing the value the server last confirmed rather than the one being sent. The input is off for the duration, so nobody can change it twice; if the write then fails, there is nothing on screen to take back.
className and style dress the <label>; every other prop is the input’s. id, name, value, required, form and aria-describedby all land where a form library and a screen reader expect to find them.
[data-slot="checkbox"] is the one selector into this component, and it is on the label — so it reaches the box and its text together. Reach for it to lay a column of options out; do not reach past it for the box.
A validation state is not in this iteration. Do not paint one through className: an error belongs beside the box as text, with aria-invalid on the input, until the component documents a variant for it.
A box that stands for others
The arrangement mixed exists for. The summary below reads the options under it: all of them ticked gives a tick, none gives an empty box, and any split between the two gives the bar — arithmetic you do and pass in, since nothing in the component knows what the box stands for. Clicking the summary resolves the split rather than cycling back into it: tick them all, or clear them all.
| Prop | Type | Default | Notes |
|---|---|---|---|
label | ReactNode | — | Text beside the box, in a span of the component’s own and inside the <label>, so a click on the words toggles. Omit it and the box is unnamed until your own <label htmlFor> names it. |
icon | "check" | "add" | "check" | Which mark the filled box draws. Nothing else changes. |
checked | boolean | — | Controlled value. Pair it with onChange, or the box paints what you passed and refuses to move. |
defaultChecked | boolean | false | Initial value when uncontrolled. The browser holds it from there. |
indeterminate | boolean | false | The mixed rendering. A property of the node with no attribute behind it, which is why it is a prop here rather than something the spread can carry. |
onChange | (event: ChangeEvent<HTMLInputElement>) => void | — | Every change, whether it came from the box, from the text beside it or from the space bar. |
disabled | boolean | false | Non-interactive, and repainted rather than faded. loading sets it as well. |
loading | boolean | false | A ring in place of the box, with the input switched off and aria-busy set for as long as it spins. |
children | never | — | The box takes none. Its text arrives through label, so the component owns the span it goes in. |