Checkbox
A box that is either ticked or not, with the label that says what it means.
Overview
A box that is either ticked or not, with the label that says what it means.
First example
<Checkbox isSelected={accepted} onSelectedChange={setAccepted}>J'accepte les conditions</Checkbox><Checkbox defaultSelected onSelectedChange={save}>Recevoir la lettre d'information</Checkbox>
Anatomy
<Checkbox>
<Checkbox.Indicator />
<Checkbox.Label />
</Checkbox>
Checkbox— the root, and it is the row, not the box. It is the pressable: it owns the selected state, resolves the recipe once (R5) and publishes it to its slots.Checkbox.Indicator— the box, and the mark inside it. With no children it draws its own check.Checkbox.Label— what ticking the box means.
The root is the row so that tapping the label toggles the checkbox. That is the whole
reason the label is a slot here rather than a Text you put beside the component and wire
up yourself — and it is why PressableProps are on the root.
R3 — and the box comes with the auto-wrap. A stringifiable tree becomes the label and the root supplies the indicator, because a checkbox without one is not a checkbox:
<Checkbox>J'accepte</Checkbox>
// is exactly
<Checkbox>
<Checkbox.Indicator />
<Checkbox.Label>J'accepte</Checkbox.Label>
</Checkbox>
Written with no children at all, it is the box alone — the form you want inside a table row, where the label is a cell of its own. Anything else and the arrangement is yours.
No slot carries a margin (R4). What separates the box from its label is the root's
gap, so JSX order is screen order — a label before the box is a matter of where you
wrote it.
Usage
Controlled, or not
<Checkbox isSelected={accepted} onSelectedChange={setAccepted}>
J'accepte les conditions
</Checkbox>
<Checkbox defaultSelected onSelectedChange={save}>
Recevoir la lettre d'information
</Checkbox>
isSelected present means the caller owns the value; defaultSelected means the checkbox
keeps it. onSelectedChange fires either way, and so does onPress — the tick and your
handler both happen, in that order.
Invalid
<Checkbox isInvalid={!accepted} isSelected={accepted} onSelectedChange={setAccepted}>
Il faut accepter pour continuer
</Checkbox>
The border, the mark's fill and the label turn danger, and the resting fill is
dropped: a box that is wrong reads as an outline rather than as a filled control that
happens to be red at the edge. The reference implementation reaches the same shape with a compound.
It does not mount a message. Put one under the row yourself — a slot that silently renders
nothing is a slot you cannot debug, which is the TextField.Error bargain again.
Indeterminate
<Checkbox isIndeterminate={some && !all} isSelected={all} onSelectedChange={setAll}>
Tout sélectionner
</Checkbox>
Neither ticked nor empty — the state a "select all" sits in while some of its rows are.
The box fills as if selected and the mark is a dash, a screen reader hears mixed, and a
press resolves it to selected rather than toggling into it: "some of these" is a state
a control reports, never one a person picks.
It is a display state and does not touch isSelected — the caller decides when the
tri-state collapses, because only the caller knows what the rows say.
Disabled
<Checkbox isDisabled defaultSelected>
Verrouillé
</Checkbox>
On the row, not the box: what is disabled is the control, and the label is part of it. R8
keeps disabled off the public surface.
A label that wraps
<Checkbox alignItems="flex-start" maxWidth={320}>
<Checkbox.Indicator />
<Checkbox.Label>J'accepte que ces informations soient conservées…</Checkbox.Label>
</Checkbox>
The box centres on the row, which is right for one line and wrong for a paragraph. A style prop moves it (R14) — no second API for it.
A mark of your own
<Checkbox.Indicator>
<Icon as={CheckIcon} size={14} color={theme.colors.accentForeground} />
</Checkbox.Indicator>
Children replace the built-in check and ride the same fade. The built-in one exists so a
checkbox works in a project that has installed no icon set — @xaui/icons was deleted in
P0 — not to stop you having your own.
As another element
<Checkbox asChild isSelected={on} onSelectedChange={setOn}>
<Animated.View layout={LinearTransition}>…</Animated.View>
</Checkbox>
R12 — the caller's element is the row, so it takes the children it was written with and the auto-wrap does not apply.
Root props
CheckboxProps
The node's inherited React Native props apply too, and so do its style props — padding, margin, width and the rest.
| Prop | Type | Required | Description |
|---|---|---|---|
| variant | CheckboxVariant | undefined | No | — |
| size | CheckboxSize | undefined | No | The box, the glyph inside it, the gap and the label's type. Never width. |
| radius | RadiusKey | undefined | No | Overrides the corner the size chose. |
| color | string | undefined | No | A raw tint (`'#7c3aed'`), never a token (R7). It is **the colour the box checks in**, with a mark derived to read against it — the accent is only the default. It is ignored while `isInvalid`: an error outranks a brand colour, the same way it outranks focus on the `Input`. |
| isSelected | boolean | undefined | No | Controlled. Leave it out and the checkbox keeps its own state. |
| defaultSelected | boolean | undefined | No | The starting value when uncontrolled. |
| onSelectedChange | ((isSelected: boolean) => void) | undefined | No | Fired with the new value on every tick, controlled or not. |
| isIndeterminate | boolean | undefined | No | Neither ticked nor empty — the state a "select all" sits in while some of its rows are. The box fills as if selected and the mark is a dash; a screen reader hears `mixed`, and a press resolves it to selected, which is what a browser's own indeterminate checkbox does. It is a **display** state and does not touch `isSelected`: the caller decides when the tri-state collapses, because only the caller knows what the rows say. |
| isInvalid | boolean | undefined | No | Paints the border, the fill and the label in `danger`. |
| isDisabled | boolean | undefined | No | Dims the row and stops the press. |
| animation | AnimationProp | undefined | No | — |
| style | StyleProp<ViewStyle> | ((state: PressableStateCallbackType) => StyleProp<ViewStyle>) | No | R9 — `Pressable`'s function form as much as an object or an array. |
| children | ReactNode | No | — |
Slots
CheckboxIndicatorProps
The node's inherited React Native props apply too, and so do its style props — padding, margin, width and the rest.
| Prop | Type | Required | Description |
|---|---|---|---|
| children | ReactNode | No | Replaces the built-in check — an icon set's glyph, a dash, anything. It is rendered inside the fill, so it appears and disappears with it. |
| animation | boolean | undefined | No | `false` shows the mark without the fade and the scale. |
| style | StyleProp<ViewStyle> | No | — |
CheckboxLabelProps
The node's inherited React Native props apply too, and so do its style props — padding, margin, width and the rest.
| Prop | Type | Required | Description |
|---|---|---|---|
| children | ReactNode | No | — |
Variants, sizes and colour
Sizes
size | Box | Corner | Check | Gap | Label |
|---|---|---|---|---|---|
sm | 20 | sm (6) | 10 × 5 · 2 | 8 | 14/20 |
md | 24 | md (9) | 12 × 6 · 2 | 8 | 16/24 |
lg | 28 | md (9) | 14 × 7 · 2.5 | 10 | 18/28 |
size drives the box, the glyph, the gap and the label's type — never width. A
checkbox hugs its label; a row that has to fill its parent is a style prop away.
The check is derived from the box — half its width, a quarter its height — rather than tabulated, so it is one glyph at four sizes instead of four drawings of one.
md is the reference implementation's checkbox measured: a 24pt box with the field's 1pt border. The corner is
md (9) where theirs is lg (8) — the same key is 12 on our radius base, and 12 on a 24pt
box is a circle, which is the Radio.
Variants
Three of the TextField's four levels, meaning here what they mean there — this is the
field* family again, on a box 24pt wide instead of a field 48pt tall.
variant | Box at rest | Border | Shadow |
|---|---|---|---|
primary | fieldBackground | fieldBorder | field |
secondary | default | fieldBorder | — |
tertiary | transparent | fieldBorder | — |
ghost is absent, where the TextField has it: a field with no border is still a line of
text you can see; a checkbox with no border and no fill is nothing at all.
A variant describes the box at rest. Ticked, all three are the accent — or color.
Colour
<Checkbox color="#7c3aed" defaultSelected>
Teinté
</Checkbox>
A raw tint, never a token (R7), and on this component it is the colour the box checks in, with a mark derived to read against it.
That works because the selected fill is a role — bgSelected, with fgSelected for
the mark — rather than a token named in an isSelected axis. The tint pass re-runs paint
and the states, never the axes, so a fill written as an axis would have been the accent
again the moment the box was ticked. Radio and Switch need the same pair, which is why
it is in the engine rather than here.
The tint is ignored while isInvalid. An error outranks a brand colour, the same way it
outranks focus on the TextField — and unlike the TextField, where a tint and an error currently
fight over the border, this one is decided in the root and not left to resolution order.
Accessibility
accessibilityRole="checkbox"on the root, overridable, withaccessibilityState.checkedfollowing the value and.disabledfollowingisDisabled. A caller's ownaccessibilityStateis merged, not spread over: naming one key must not silently drop the two a screen reader reads this control by.aria-invalidfollowsisInvalid.- The label is inside the control, so the checkbox announces itself with its text
instead of as an unnamed box. A checkbox with no label needs an
accessibilityLabel. - The whole row is the touch target, which is what makes a 24pt box reachable;
hitSlopisPressable's and still available for the case where it is not. - The built-in check mirrors under RTL, because
borderStartWidthis a logical edge and R13 leaves no other kind. A check reads as a check either way.
Migration from legacy
| Legacy | v1 |
|---|---|
label="…" | <Checkbox>…</Checkbox> — the text child is the label |
labelAlignment="left" | write <Checkbox.Label> before <Checkbox.Indicator> |
labelAlignment="justify-*" | justifyContent="space-between" plus a width, in style props |
isChecked / onValueChange | isSelected / onSelectedChange, plus defaultSelected |
isIndeterminate | isIndeterminate — unchanged, and now mixed in a11y too |
isDisabled | unchanged, on the root |
themeColor="primary" | color={theme.colors.accent} — a raw value (R7) |
variant="filled" | variant="tertiary" — a border at rest, the accent once ticked |
variant="light" | gone: a mark with no box. <Checkbox.Indicator> with your own glyph |
size="sm" | "md" | "lg" | size — the same three |
radius | radius — a RadiusKey now, not the legacy Radius |
fullWidth | width="100%" in style props (R14) |
labelStyle | style on <Checkbox.Label> |
style | style on the root |
Implementation notes
Selection is not a style axis
isSelected never reaches the recipe. The fill and the mark are two slots the indicator
mounts only while it is ticked, painted unconditionally from the roles above.
That buys two things at once: the style cache keeps one entry per token combination instead
of two, and color reaches the selected fill — which an axis would have skipped.
Animation
The fill fades and grows in over 120ms, and the mark rides with it: a check arriving before
its background reads as a glitch rather than as an animation. <Checkbox.Indicator animation={false} /> turns it off, and then the Reanimated hooks are never reached at all —
two components rather than a branch inside one.
The press treatment is PressableFeedback's, so animation on the root is the library's
usual knob: false, 'disabled', 'disable-all', or the object.
Alignment with the reference implementation
Measured against their checkbox.tsx and checkbox.css rather than eyeballed.
Identical: the 24pt box, the field border and its width, the field fill plus shadow on
primary and the neutral default on secondary, the accent fill with an
accentForeground mark, the danger treatment including the dropped resting fill, the fade
and scale on the mark, and a built-in check so no icon set is required.
Four deltas:
| Theirs | Ours | Why |
|---|---|---|
The root is the box; a label needs ControlField | The root is the row, Label is a slot | The plan's slots for this component are Indicator · Label, and a label that does not toggle the box is a bug waiting to be filed. |
variant auto-switches on a surface | secondary by default | We have no surface detection, and the TextField already answers this the same way. |
| One size | Four | Every control in this library has the same ladder. |
Checkbox.Background (a glass layer) | — | A theme-registered blur layer belongs to their Uniwind theming, which is the premise XAUI does not share. |
We have isIndeterminate and they do not. The legacy @xaui/native-legacy checkbox
had it, a "select all" is the reason it exists, and accessibilityState.checked: 'mixed' is
a thing only the component can say.
Extending it
The context hook is exported, so a third party can write its own slot against the same resolved values the built-in ones read:
import { useCheckbox } from '@xaui/native/checkbox'
function CheckboxDescription({ children }) {
const { isInvalid } = useCheckbox()
return <Text style={{ opacity: isInvalid ? 1 : 0.7 }}>{children}</Text>
}
Used outside a <Checkbox> it throws by name, pointing at the misplaced component rather
than failing three frames later on an undefined style.