BetaForms

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 own isSelected works 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.

PropTypeRequiredDescription
variantRadioVariant | undefinedNo
sizeRadioSize | undefinedNoThe circle, the dot inside it, the gap and the label's type. Never width.
radiusRadiusKey | undefinedNoOverrides 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.
colorstring | undefinedNoA 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`.
valuestring | undefinedNoWhat 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`.
isSelectedboolean | undefinedNoControlled. 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.
defaultSelectedboolean | undefinedNoThe starting value when uncontrolled.
onSelectedChange((isSelected: boolean) => void) | undefinedNoFired 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.
isInvalidboolean | undefinedNoPaints the border, the fill and the label in `danger`. A group's still applies.
isDisabledboolean | undefinedNoDims the row and stops the press. A group's still applies.
animationAnimationProp | undefinedNo
styleStyleProp<ViewStyle> | ((state: PressableStateCallbackType) => StyleProp<ViewStyle>)NoR9 — `Pressable`'s function form as much as an object or an array.
childrenReactNodeNo

Slots

RadioGroupProps

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

PropTypeRequiredDescription
valuestring | undefinedNoThe chosen option's `value`. Controlled — leave it out and the group holds its own.
defaultValuestring | undefinedNoThe option chosen at first mount, when uncontrolled.
onValueChange((value: string) => void) | undefinedNoFired 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.
orientationRadioGroupOrientation | undefinedNo
variantRadioVariant | undefinedNoThe default for every option in the set. An option's own prop still wins.
sizeRadioSize | undefinedNoThe options' scale, and the gap between them. Never width.
radiusRadiusKey | undefinedNoThe default corner for every option in the set.
colorstring | undefinedNoA raw tint (`'#7c3aed'`), never a token (R7), applied to every option.
isDisabledboolean | undefinedNoDims every option and stops the press. An option cannot opt back in.
isInvalidboolean | undefinedNoPaints every option in `danger` — the set is wrong, not one row of it.
styleStyleProp<ViewStyle>No
childrenReactNodeNo

RadioIndicatorProps

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 dot. It is rendered inside the fill, so it arrives with it.
animationboolean | undefinedNo`false` shows the dot without the fade and the scale.
styleStyleProp<ViewStyle>No

RadioLabelProps

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

sizeCircleDotGapLabel
sm208814/20
md2410816/24
lg28121018/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:

variantCircle at restBorderShadow
primaryfieldBackgroundfieldBorderfield
secondarydefaultfieldBorder
tertiarytransparentfieldBorder

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, with accessibilityState.checked following the value and .disabled following isDisabled. A caller's own accessibilityState is merged, not spread over.
  • aria-invalid follows isInvalid.
  • accessibilityRole="radiogroup" on Radio.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

Legacyv1
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 / defaultCheckedisSelected / defaultSelected
onValueChangeonSelectedChange — 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
radiusradius — a RadiusKey now, and still full by default
fullWidthwidth="100%" in style props (R14)
isDisabledunchanged, on the root
labelStylestyle on <Radio.Label>
stylestyle 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:

TheirsOursWhy
The root is the circle; a label needs ControlFieldThe root is the row, Label is a slotThe 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 partThe dot is the indicator's default childTwo 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 ownThe fill and the dot arrive togetherOne 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.