BetaForms

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.

PropTypeRequiredDescription
variantCheckboxVariant | undefinedNo
sizeCheckboxSize | undefinedNoThe box, the glyph inside it, the gap and the label's type. Never width.
radiusRadiusKey | undefinedNoOverrides the corner the size chose.
colorstring | undefinedNoA 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`.
isSelectedboolean | undefinedNoControlled. Leave it out and the checkbox keeps its own state.
defaultSelectedboolean | undefinedNoThe starting value when uncontrolled.
onSelectedChange((isSelected: boolean) => void) | undefinedNoFired with the new value on every tick, controlled or not.
isIndeterminateboolean | undefinedNoNeither 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.
isInvalidboolean | undefinedNoPaints the border, the fill and the label in `danger`.
isDisabledboolean | undefinedNoDims the row and stops the press.
animationAnimationProp | undefinedNo
styleStyleProp<ViewStyle> | ((state: PressableStateCallbackType) => StyleProp<ViewStyle>)NoR9 — `Pressable`'s function form as much as an object or an array.
childrenReactNodeNo

Slots

CheckboxIndicatorProps

The node's inherited React Native props apply too, and so do its style props padding, margin, width and the rest.

PropTypeRequiredDescription
childrenReactNodeNoReplaces 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.
animationboolean | undefinedNo`false` shows the mark without the fade and the scale.
styleStyleProp<ViewStyle>No

CheckboxLabelProps

The node's inherited React Native props apply too, and so do its style props padding, margin, width and the rest.

PropTypeRequiredDescription
childrenReactNodeNo

Variants, sizes and colour

Sizes

sizeBoxCornerCheckGapLabel
sm20sm (6)10 × 5 · 2814/20
md24md (9)12 × 6 · 2816/24
lg28md (9)14 × 7 · 2.51018/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.

variantBox at restBorderShadow
primaryfieldBackgroundfieldBorderfield
secondarydefaultfieldBorder
tertiarytransparentfieldBorder

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 rolebgSelected, 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, with accessibilityState.checked following the value and .disabled following isDisabled. A caller's own accessibilityState is merged, not spread over: naming one key must not silently drop the two a screen reader reads this control by.
  • aria-invalid follows isInvalid.
  • 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; hitSlop is Pressable's and still available for the case where it is not.
  • The built-in check mirrors under RTL, because borderStartWidth is a logical edge and R13 leaves no other kind. A check reads as a check either way.

Migration from legacy

Legacyv1
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 / onValueChangeisSelected / onSelectedChange, plus defaultSelected
isIndeterminateisIndeterminate — unchanged, and now mixed in a11y too
isDisabledunchanged, 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
radiusradius — a RadiusKey now, not the legacy Radius
fullWidthwidth="100%" in style props (R14)
labelStylestyle on <Checkbox.Label>
stylestyle 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:

TheirsOursWhy
The root is the box; a label needs ControlFieldThe root is the row, Label is a slotThe 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 surfacesecondary by defaultWe have no surface detection, and the TextField already answers this the same way.
One sizeFourEvery 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.