Skip to content

Selection

Radio

Beta

A set of mutually exclusive options, available on web (React) and native (React Native). One component owns the group — the selected value, the shared name, the arrow-key model, the single tab stop and the accessible group name — and renders its options from data.

Basic

A group of options

One component owns the group and renders its options from data, so an individual radio never manages its own checked state. The group renders a real fieldset named by its legend, which is what makes assistive tech announce a named group rather than a run of unrelated radios.

Contact preference
<Radio label="Contact preference" defaultValue="email" items={[
{ value: 'email', label: 'Email' },
{ value: 'phone', label: 'Phone' },
{ value: 'post', label: 'Post' },
]} />

Controlled and uncontrolled

Controlled

The parent owns the value and the component only reports changes through onChange. If the parent ignores the callback, the selection does not move and every option keeps rendering the parent's value.

Contact preference
const [contact, setContact] = useState('email');
<Radio label="Contact preference" value={contact} onChange={setContact} items={[
{ value: 'email', label: 'Email' },
{ value: 'phone', label: 'Phone' },
{ value: 'post', label: 'Post' },
]} />

Uncontrolled, nothing selected

Omit value and the component owns its own selection. Nothing is selected here, so the first enabled option takes the group's single tab stop.

Contact preference
<Radio label="Contact preference" items={[
{ value: 'email', label: 'Email' },
{ value: 'phone', label: 'Phone' },
{ value: 'post', label: 'Post' },
]} />

With a descriptor

Group descriptor and option descriptor

Both platform components expose a group descriptor and a per-option descriptor. On web, the group's descriptor sits beneath the legend and is announced as the group's description, and an option's descriptor is announced as that option's description, separately from its label. On native the option descriptor is folded into the option's name instead, and the group's descriptor is not announced — see Accessibility.

Contact preference

We only use this to contact you about your job.

<Radio label="Contact preference" descriptor="We only use this to contact you about your job." defaultValue="email" items={[
{ value: 'email', label: 'Email', descriptor: 'Replies within one working day' },
{ value: 'phone', label: 'Phone', descriptor: 'Weekdays, 9am to 5pm' },
]} />

Sizes

Small

Dense UI, compact forms. The circle is 20px — see Target size under Accessibility.

Small
<Radio label="Small" size="sm" defaultValue="a" items={…} />

Medium

The default size for most layouts.

Medium
<Radio label="Medium" size="md" defaultValue="a" items={…} />

Large

Prominent, standalone choices.

Large
<Radio label="Large" size="lg" defaultValue="a" items={…} />

Disabled

Whole group disabled

A disabled group disables every option and removes them all from the tab order, while keeping the group's semantics — a screen reader still announces what the group is.

Contact preference
<Radio label="Contact preference" defaultValue="email" disabled items={…} />

One option disabled

A disabled option is skipped by arrow navigation, not focused-and-inert.

Contact preference
<Radio label="Contact preference" defaultValue="email" items={[
{ value: 'email', label: 'Email' },
{ value: 'phone', label: 'Phone', disabled: true },
]} />

Form usage

Form field

name is shared by every option's input, and required blocks submission until one is chosen. That is the reason to reach for Radio over a set of custom buttons.

Contact preference
<form>
  <Radio name="contact" label="Contact preference" required items={[
    { value: 'email', label: 'Email' },
    { value: 'phone', label: 'Phone' },
  ]} />
</form>

When to use

Use Radio when the user must choose one option from a small set and the options should all be visible at once. Because the web component renders real <input type="radio"> elements sharing one name, it participates in normal form submission and HTML5 validation.

When not to use

  • An independent boolean: use a checkbox. Checking one checkbox does not uncheck its neighbours.
  • A setting that applies immediately, with no save/submit step: use a switch.
  • A long list of options: use a select. A radio group shows every option at once, which stops scaling past roughly seven.
  • A single yes/no choice: use a Checkbox or a Switch. There is no standalone radio component — Radio always renders a group, so a “single radio” is not representable.

Choosing between Radio, Checkbox and Switch

Use a radio group when…Use a checkbox when…Use a switch when…
The user picks one option from a set (mutually exclusive).The user toggles an independent boolean on or off.A setting applies immediately, with no save/submit.
The options are only meaningful together, as a group.Each item stands alone; several can be on at once.It is one setting, not a set of options.
A selection is normally always present.“Off” is a meaningful, common state.The on/off framing itself is the affordance.

Radio is the only one of the three that is inherently a set: it owns the selected value, so an option cannot drift out of step with its siblings. A Checkbox and a Switch are each a complete control on their own, which is why they carry their own checked/onChange and Radio does not.

Keyboard

Web (packages/web) only. The whole group is a single tab stop: Tab moves into the group and lands on the selected option (or the first enabled one when nothing is selected), and the next Tab moves out.

  • Down / Right move focus forward and select, in one gesture.
  • Up / Left move focus back and select.
  • Both wrap at the ends of the group.
  • Disabled options are skipped, not focused-and-inert.
  • Space selects the focused option.

Native has no keyboard model: on a touch device there are no arrow keys, and VoiceOver/TalkBack handle radiogroup traversal themselves.

On react-native-web, the group is N tab stops — not one. RN-web is a production surface (the trade app renders React Native on web), and it does not inherit the single-tab-stop behaviour above: every option carries tabindex="0", so Tab walks each item in turn. Adding a roving tabIndex alone would leave the other N−1 radios keyboard-unreachable, so a roving model there requires the arrow-key model first — a follow-up, not a one-line change.

Radio props

Prop Type Default Description
items RadioItem[] - Required. The options, in render order. Arrow navigation follows this order. Each entry is { value, label?, descriptor?, disabled? } — see the item table below.
value string - Controlled selection. Omit and use defaultValue for uncontrolled usage.
defaultValue string - Initial selection for uncontrolled usage. Ignored if value is provided.
onChange (value: string) => void - Fires with the newly selected value — not the DOM event.
name string - Web only. The shared name every option input carries. Generated via useId() when omitted.
label string - Web: rendered as <legend> — the group's accessible name. A <fieldset> is named by its first <legend>, which is why this is not a <label>. Native: the visible group label, and the group's accessible name unless accessibilityLabel is set.
descriptor string - Optional secondary line beneath the label. Only rendered when label is also set. Web: wired to the <fieldset> via aria-describedby; it is a sibling of the legend, not inside it, because <legend>'s content model is phrasing content. Native: it renders, but is not announced — see Known gaps.
size 'sm' | 'md' | 'lg' 'md' Size of every option in the group.
disabled boolean false Disables every option, while keeping the group semantics. Web only: also removes them all from the tab order.
required boolean false Web only. Propagates required to each option input for HTML5 validation.
accessibilityLabel string label Native only. Overrides the group accessible name; falls back to label.
id string - Web only. Generated via useId() if omitted; derives the -legend and -descriptor ids and every option input id.
testID string - Native only. On the container. Derives -legend and, per option, -item-{value} plus -wrapper, -text and -dot.

Item shape (RadioItem)

Prop Type Default Description
value string - Required, and unique within the group. The option is selected when it matches the group value.
label string - Optional label text. When set, renders the composite (circle + label + optional descriptor). On web the wrapper is a real <label>, so the whole row is one click target; on native the circle and the text block are separate tap targets — see Accessibility.
descriptor string - Optional secondary text beneath the label. Only rendered when label is set.
disabled boolean false Disables this option only. A disabled option is skipped by arrow navigation, not focused-and-inert.

The options are data, not elements, and that is deliberate: it makes the invalid states unrepresentable. There is no per-option checked to disagree with the group, no per-option size to make a ragged group, and no per-option labelPosition — every option renders control-plus-text in one arrangement. Radio also blocks the visual escape hatches className/style, and does not accept children or orientation. Those are all type errors, not runtime warnings.

RadioGroup no longer exists as a separate export. It was collapsed into Radio: the group was always the unit that owned the value, so the pair is now one component.

Import

Web

import { Radio } from '@checkatrade/components-web';
import type { RadioItem, RadioProps, RadioSize } from '@checkatrade/components-web';

Native

import { Radio } from '@checkatrade/components-native';
import type { RadioItem, RadioProps, RadioSize } from '@checkatrade/components-native';

Basic usage

Controlled (both platforms):

const [contact, setContact] = useState('email');
<Radio
  label="Contact preference"
  value={contact}
  onChange={setContact}
  items={[
    { value: 'email', label: 'Email' },
    { value: 'phone', label: 'Phone' },
  ]}
/>;

Uncontrolled (both platforms):

<Radio
  label="Contact preference"
  defaultValue="email"
  items={[
    { value: 'email', label: 'Email' },
    { value: 'phone', label: 'Phone' },
  ]}
/>

Form submission (web only):

<form>
  <Radio
    name="contact"
    label="Contact preference"
    required
    items={[
      { value: 'email', label: 'Email' },
      { value: 'phone', label: 'Phone' },
    ]}
  />
</form>

Platform status

Platform / AreaStatus
Design (Figma) Planned
Web (React) Beta
Native (React Native) Beta
iOS (Swift) Planned
Android (Kotlin) Planned
Accessibility audit Planned

Accessibility

Web

  • Renders a real <input type="radio"> per option, all sharing one name — which is what gives native form submission, required validation and form-library integration their hook. The circle is decorative (aria-hidden="true"); state lives on the input.
  • The group is a real <fieldset> named by its <legend>, so it announces itself as a group with a name.
  • Each option’s own label becomes its accessible name via aria-labelledby, and its descriptor its accessible description via aria-describedby — announced separately, never concatenated.
  • The whole group is one tab stop. Arrow keys move focus and selection together; disabled options are skipped.
  • A disabled group keeps its semantics, so a screen reader still announces what the group is.

React Native

  • The container carries accessibilityRole="radiogroup" and the group’s accessible name; each option is a Pressable with role="radio" and accessibilityState={{ checked, disabled }}.
  • aria-checked is emitted explicitly. react-native-web maps accessibilityRole and accessibilityLabel but silently drops accessibilityState, so without it an RN-web radio group never announces which option is selected. The same gap exists on Checkbox and Switch and is tracked separately.
  • The accessible name of an option is its label with the descriptor appended — native has no separate description slot, so a descriptor that stayed out of the name would be silent.
  • The label and descriptor text is rendered visually but hidden from assistive tech, so each option is announced once rather than twice.
  • The text block is its own press target. Pressing the label or descriptor selects the option through the same handler as the circle, but it is not one continuous target: the wrapper is a plain View with an 8px gap between the two Pressables, so a tap in the gap does nothing. The press is a no-op when disabled.

Target size

  • Web: the circle is 20 / 24 / 32px at sm / md / lg. With a label, the whole row is the click target — the wrapper is a real <label>.
  • Native: the circle and the text block are each their own tap target, separated by the wrapper’s 8px gap, so a tap in the gap does nothing and the circle stays 20 / 24 / 32px even when the option is labelled. At size="sm" the circle alone is a 20×20 target, below the 24×24 WCAG 2.2 SC 2.5.8 minimum. Flagged to Design alongside the touch-target policy for other controls.

Known gaps

  • Not surface-tone aware. The control/* colour tokens define only the base control colours — there are no inverse variants — so inside a <Surface tone="inverse"> a radio keeps its light-mode control colours. This matches Checkbox, Switch and TextInput, and is a token request for Design rather than a component omission.
  • Native does not announce the group’s descriptor. It renders, but inside the block hidden from assistive tech, so a web user hears the group’s descriptor and a native user does not. It is not platform-forced — React Native has accessibilityHint, and an option folds its own descriptor into the accessible name — and it is recorded here as a known gap rather than fixed in this pass.

Provisional

There is no Figma file for Radio yet (DSK-96 is in progress), so every visual value — the circle sizes, the dot’s diameter and inset, the colours, the hover treatment — is provisional and will be corrected against Figma. The behaviour that is forced by platform semantics (the group, roving focus, arrow keys, the accessibility model) is not provisional.

The dot’s diameter is the clearest example of a judgement call rather than a Figma value: nothing in the token set expresses a dot’s proportion relative to its circle, so the component binds the generic sizing ramp — the same ramp the circle itself binds — at --sizing-2 / 3 / 4 (8 / 12 / 16px at sm / md / lg, i.e. 40% / 50% / 50% of the circle, with a 6 / 6 / 8px inset). That mapping is the single line Design is expected to correct. (It previously bound the icon-size ramp one step down from the circle’s own icon size — --icon-size-xxs / xs / xl at 16 / 20 / 28px, i.e. 80–88% of the circle. Because the dot is the control-surface colour — white on a white page in light mode — that coverage rendered a selected radio as an outline rather than a dot. Dark mode looked correct, which is why it shipped.)

No releases yet.

Select a brand to demo:
Active brand: checkatrade