Radio
One option out of a set, and the label that says which.
Overview
One option out of a set, and the label that says which.
First example
<Radio isSelected={plan === 'yearly'} onSelectedChange={() => setPlan('yearly')}>Tous les ans — deux mois offerts</Radio><Radio defaultSelected>Choisie au départ</Radio>
Anatomy
<Radio.Group>
<Radio>
<Radio.Indicator />
<Radio.Label />
</Radio>
</Radio.Group>
Radio— the root, and it is the row, not the circle. It is the pressable: it owns the selected state, resolves the recipe once (R5) and publishes it to its slots.Radio.Indicator— the circle, and the dot inside it. With no children it draws its own dot.Radio.Label— what choosing this option means.Radio.Group— the set, and the one option in it that is chosen. Optional: a radio over its ownisSelectedworks without one.
It is the Checkbox in a circle, with one rule changed: a
press selects, it never clears. A set of options has no "none of these" unless one of
them says so, and a radio you could tap back to empty would be a checkbox that happens to
be round. onSelectedChange therefore only ever fires with true, and pressing the chosen
option fires nothing at all.
Everything else is the checkbox's, deliberately: the root is the row so the label chooses the option, R3 wraps a text child into the label and supplies the circle, the three variants and the four sizes are the same tokens, and no slot carries a margin (R4).
Usage
Controlled, or not
<Radio isSelected={plan === 'yearly'} onSelectedChange={() => setPlan('yearly')}>
Tous les ans — deux mois offerts
</Radio>
<Radio defaultSelected>Choisie au départ</Radio>
isSelected present means the caller owns the value; defaultSelected is for the
standalone case. Inside a Radio.Group neither is needed — the set is the controlled source
— and isSelected still outranks it, which is what lets one option in a group be driven by
something the group knows nothing about. onPress fires either way, composed with the
selection: it is what a wrapper listens to when it wants the press rather than the change.
Invalid
<Radio isInvalid>Aucune option choisie</Radio>
The border, the fill and the label turn danger, and the resting fill is dropped: an
option that is wrong reads as an outline. On a set, put it on the group — the error
belongs to the question, not to one of its answers.
Disabled
<Radio isDisabled defaultSelected>
Indisponible
</Radio>
On the row, not the circle: what is disabled is the control, and the label is part of it.
A mark of your own
<Radio.Indicator>
<Icon as={CheckIcon} size={12} color={theme.colors.accentForeground} />
</Radio.Indicator>
Children replace the built-in dot and ride the same fade.
As another element
<Radio asChild isSelected={isChosen} onSelectedChange={choose}>
<Animated.View layout={LinearTransition}>…</Animated.View>
</Radio>
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
RadioProps
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 | RadioVariant | undefined | No | — |
| size | RadioSize | undefined | No | The circle, the dot inside it, the gap and the label's type. Never width. |
| radius | RadiusKey | undefined | No | Overrides the corner, which is `full` — a radio is a circle, and that is the one thing about it a caller is unlikely to want to change. It is here because every control in the library has it, and because a squared-off option in a segmented row is a real design. |
| color | string | undefined | No | A raw tint (`'#7c3aed'`), never a token (R7): the colour the option takes once it is the chosen one, with a dot derived to read against it. Ignored while `isInvalid`, as on the `Checkbox`. |
| value | string | undefined | No | What this option stands for inside a [`Radio.Group`](./radio-group.tsx). The group holds the chosen value; this is the one the option compares it to. It is the only thing that puts an option in a set — nothing walks the children — so a radio nested in a card or a `List.Item` belongs to the set exactly as much as a direct child does, and one without a `value` is simply not in it, whatever it is nested in. Outside a group it means nothing: use `isSelected`. |
| isSelected | boolean | undefined | No | Controlled. Leave it out and the radio keeps its own state — or, inside a `Radio.Group`, takes it from the set. Given here it wins over the set, which is what lets one option in a group be driven by something else. |
| defaultSelected | boolean | undefined | No | The starting value when uncontrolled. |
| onSelectedChange | ((isSelected: boolean) => void) | undefined | No | Fired when the option becomes the chosen one — **`true` and only `true`**. Pressing a selected radio changes nothing: a set of options has no "none of these" unless one of them says so, which is a `Checkbox`'s job or another option's. Inside a `Radio.Group` it still fires, beside the group's `onValueChange` — one is the option's, the other the set's, and a row that needs to react to being chosen should not have to read the value it already stands for. |
| isInvalid | boolean | undefined | No | Paints the border, the fill and the label in `danger`. A group's still applies. |
| isDisabled | boolean | undefined | No | Dims the row and stops the press. A group's still applies. |
| 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
RadioGroupProps
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 |
|---|---|---|---|
| value | string | undefined | No | The chosen option's `value`. Controlled — leave it out and the group holds its own. |
| defaultValue | string | undefined | No | The option chosen at first mount, when uncontrolled. |
| onValueChange | ((value: string) => void) | undefined | No | Fired with the newly chosen option's `value`. It never fires with `undefined`: a press selects and never clears, so a group that has a chosen option keeps one. |
| orientation | RadioGroupOrientation | undefined | No | — |
| variant | RadioVariant | undefined | No | The default for every option in the set. An option's own prop still wins. |
| size | RadioSize | undefined | No | The options' scale, and the gap between them. Never width. |
| radius | RadiusKey | undefined | No | The default corner for every option in the set. |
| color | string | undefined | No | A raw tint (`'#7c3aed'`), never a token (R7), applied to every option. |
| isDisabled | boolean | undefined | No | Dims every option and stops the press. An option cannot opt back in. |
| isInvalid | boolean | undefined | No | Paints every option in `danger` — the set is wrong, not one row of it. |
| style | StyleProp<ViewStyle> | No | — |
| children | ReactNode | No | — |
RadioIndicatorProps
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 dot. It is rendered inside the fill, so it arrives with it. |
| animation | boolean | undefined | No | `false` shows the dot without the fade and the scale. |
| style | StyleProp<ViewStyle> | No | — |
RadioLabelProps
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 | Circle | Dot | Gap | Label |
|---|---|---|---|---|
sm | 20 | 8 | 8 | 14/20 |
md | 24 | 10 | 8 | 16/24 |
lg | 28 | 12 | 10 | 18/28 |
The same four boxes as the Checkbox, so a radio and a checkbox in one form line up. The
dot keeps the reference implementation's ratio — 10 in 24 — rather than being tabulated, so it is one dot at four
sizes instead of four drawings of one.
radius is full and overridable: a squared-off option in a segmented row is a real
design, and every control in this library takes the same prop.
Variants and colour
The Checkbox's three levels, on the same field* tokens:
variant | Circle at rest | Border | Shadow |
|---|---|---|---|
primary | fieldBackground | fieldBorder | field |
secondary | default | fieldBorder | — |
tertiary | transparent | fieldBorder | — |
A variant describes the circle at rest. Chosen, all three are the accent — or color,
which lands on the fill through the bgSelected role, with the dot on fgSelected. The
tint is ignored while isInvalid, as on the Checkbox.
Why a role and not an axis is written up once, in
checkbox.md, and it holds here unchanged.
Accessibility
accessibilityRole="radio"on the root, overridable, withaccessibilityState.checkedfollowing the value and.disabledfollowingisDisabled. A caller's ownaccessibilityStateis merged, not spread over.aria-invalidfollowsisInvalid.accessibilityRole="radiogroup"onRadio.Group, overridable, so a screen reader announces "2 sur 3" rather than three unrelated controls. A set built without the group needs that role on whatever wraps it.- The whole row is the touch target, which is what makes a 24pt circle reachable.
Migration from legacy
| Legacy | v1 |
|---|---|
label="…" | <Radio>…</Radio> — the text child is the label |
labelAlignment="left" | write <Radio.Label> before <Radio.Indicator> |
labelAlignment="justify-*" | justifyContent="space-between" plus a width, in style props |
isChecked / defaultChecked | isSelected / defaultSelected |
onValueChange | onSelectedChange — and it fires with true only |
themeColor="primary" | color={theme.colors.accent} — a raw value (R7) |
variant="filled" | variant="tertiary" — a border at rest, the accent once chosen |
variant="light" | gone: a dot with no circle. <Radio.Indicator> with your own mark |
size="sm" | "md" | "lg" | size — the same three |
radius | radius — a RadiusKey now, and still full by default |
fullWidth | width="100%" in style props (R14) |
isDisabled | unchanged, on the root |
labelStyle | style on <Radio.Label> |
style | style on the root |
<RadioGroup> | <Radio.Group> — same value / defaultValue / onValueChange |
The legacy RadioGroup also carried orientation — unchanged — and the shared variant /
size / radius / themeColor / labelAlignment / fullWidth for its options. The first
three are handed down as before, themeColor becomes color with a raw value (R7), and the
last two are gone with the props they mirror: labelAlignment is JSX order plus a style
prop on the row, and fullWidth is width="100%".
One difference worth reading twice: the legacy group rendered its options from an array
of items, ours takes them as children. A set of options that comes from data is a map, and
it is the same map the rest of this library asks for.
Implementation notes
The set
const [plan, setPlan] = useState('monthly')
<Radio.Group value={plan} onValueChange={setPlan}>
<Radio value="monthly">Tous les mois</Radio>
<Radio value="yearly">Tous les ans — deux mois offerts</Radio>
<Radio value="lifetime">À vie</Radio>
</Radio.Group>
Radio.Group is the context this component was written to read, not a second radio. An
option still owns its circle, its press and its recipe; what it could not know alone is
whether it is the chosen one, and that is the whole of what the group publishes.
Exclusivity comes from comparing one value, not from talking to siblings. The group
holds the chosen value, each option compares the value it stands for, and nothing walks
the children — so an option nested in a Card, a List.Item or a Fragment is in the set
exactly as much as a direct child is:
<Radio.Group defaultValue="card">
<Card>
<Radio value="card">Carte bancaire</Radio>
</Card>
<Card>
<Radio value="transfer">Virement</Radio>
</Card>
</Radio.Group>
An option with no value is not in the set, whatever it is nested in — which is what
keeps a standalone radio over its own isSelected working unchanged inside one.
What the set hands down
variant, size, radius and color are defaults, and an option that names its own
wins: a set is usually uniform, and the row that differs is a design rather than a mistake.
isDisabled and isInvalid are the two that do not work that way — a disabled set has no
enabled option in it, and a set that is wrong is wrong on every row. An option can still be
disabled on its own inside an enabled set; it cannot opt back into a disabled one.
<Radio.Group size="lg" color="#7c3aed" isDisabled>
<Radio value="a">Toutes en lg, violettes et éteintes</Radio>
<Radio value="b" variant="tertiary">
Sauf le contour, qui est à elle
</Radio>
</Radio.Group>
Orientation
<Radio.Group orientation="horizontal">…</Radio.Group>
The set lays its options out — that is R4, and the reason Radio.Group has a recipe at all.
vertical is the default; horizontal wraps, so three short labels stay one row on a phone
and become two on a narrow screen rather than overflowing off it. The gap follows size.
Uncontrolled
<Radio.Group defaultValue="monthly">…</Radio.Group>
value present means the caller owns the choice; defaultValue leaves it with the group.
onValueChange fires either way, and never with undefined: a press selects and never
clears, so a set that has a chosen option keeps one.
Alignment with the reference implementation
Measured against their radio.tsx and radio.css.
Identical: the 24pt circle, 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 dot at 10/24 of the circle, the danger treatment including the dropped
resting fill, and a built-in dot so no icon set is required.
Four deltas:
| Theirs | Ours | Why |
|---|---|---|
The root is the circle; a label needs ControlField | The root is the row, Label is a slot | The plan's slots are Indicator · Label, and a label that does not choose the option is a bug waiting to be filed. |
Radio.IndicatorThumb is a part | The dot is the indicator's default child | Two nodes to write for one dot. Children replace it, which is the same escape hatch with one component fewer. |
| The thumb scales 1.5 → 1 on its own | The fill and the dot arrive together | One SelectionFill, shared with the Checkbox: what differs between the two controls is the mark, not how it arrives. |
Radio.IndicatorBackground (a glass layer) | — | A theme-registered blur layer belongs to their Uniwind theming, which is the premise XAUI does not share. |
Their RadioGroup is a component of its own with a RadioGroup.Item inside it; ours is
Radio.Group around plain Radios. One import, one option component, and a radio that
means the same thing whether or not it is in a set — theirs has two, and the standalone one
is the one that cannot be grouped.
Extending it
useRadio() is exported (R10) and carries the resolved styles plus isSelected,
isDisabled and isInvalid — enough to write a description under the label or a price
beside it. Outside a <Radio> it throws by name.
useRadioGroup() is exported too, and carries the chosen value, select, and the
appearance the set hands down — enough to write an option of your own that is in the set
without being a Radio. Outside a <Radio.Group> it throws by name.