React Native Radio Buttons Group: Controlled Input
By Native Animation · Updated
A React Native radio buttons group stores one value from a visible list of mutually exclusive choices. This tutorial builds a controlled radio group with disabled options, labeled radio semantics, a small Reanimated selection spring, and web keyboard navigation. The parent owns the accepted value; moving focus and animating a dot never overwrite it.
Radio group, segmented control, or select?
| Pattern | Use it when | What the interaction means |
|---|---|---|
| Radio group | A short form list needs descriptions and visible alternatives | Choose exactly one stored value |
| Segmented control | A few short peer choices fit together in a compact row | Switch a setting or related view |
| Dropdown select | A longer form list would crowd the screen | Open a selection surface, then store one value |
A radio button is not an independent on/off switch. Do not let tapping the checked option clear a required field; offer a separate None option when that is a valid value. Use checkboxes for multiple independent selections.
For Android-native Material controls, Expo SDK 58 provides Expo UI RadioButton, including a recommended whole-row selection pattern. That Jetpack Compose component is Android-specific. It is a useful starting point when platform presentation fits; the custom Pressable example below shares its rendering across iOS, Android, and web.
Setup
This complete file targets Expo SDK 58, React Native 0.88.0, Reanimated 4.7.1, and Worklets 0.13.0. Install compatible animation dependencies:
npx expo install react-native-reanimated react-native-workletsUse bunx expo install in Bun projects and follow the SDK 58 Reanimated setup. Save the file as RadioGroup.tsx outside the Expo Router route directory, then render RadioExample from a screen with a light background and safe-area padding. Pressable handles selection, so this example needs no Gesture Handler wrapper or Skia setup.
Build the controlled radio group
import type { ComponentRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { I18nManager, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import Animated, { useAnimatedStyle, useReducedMotion, useSharedValue, withSpring } from 'react-native-reanimated';
type Option = { id: string; label: string; detail?: string; disabled?: boolean };
type RadioGroupProps = {
label: string;
options: readonly Option[];
value: string;
onValueChange: (id: string) => void;
};
function RadioMark({ checked }: { checked: boolean }) {
const progress = useSharedValue(checked ? 1 : 0);
const reducedMotion = useReducedMotion();
useEffect(() => {
progress.set(reducedMotion ? Number(checked) : withSpring(Number(checked), {
stiffness: 280, damping: 24, mass: 1,
}));
}, [checked, progress, reducedMotion]);
const motion = useAnimatedStyle(() => ({
opacity: progress.get(),
transform: [{ scale: progress.get() }],
}));
return (
<View accessible={false} style={styles.ring}>
<Animated.View style={[styles.dot, motion]} />
</View>
);
}
export function RadioGroup({ label, options, value, onValueChange }: RadioGroupProps) {
const host = useRef<ComponentRef<typeof View>>(null);
const rows = useRef(new Map<string, ComponentRef<typeof View>>());
const [focusedId, setFocusedId] = useState<string | null>(null);
const [rowHasFocus, setRowHasFocus] = useState(false);
const enabled = options.filter((option) => !option.disabled);
const tabStop = enabled.find((option) => option.id === focusedId)?.id
?? enabled.find((option) => option.id === value)?.id ?? enabled[0]?.id;
useEffect(() => {
if (Platform.OS !== 'web') return;
const keydown = (event: KeyboardEvent) => {
const current = enabled.findIndex((option) =>
rows.current.get(option.id) === event.target as unknown);
if (current < 0) return;
let next = current;
const forward = I18nManager.isRTL ? 'ArrowLeft' : 'ArrowRight';
const backward = I18nManager.isRTL ? 'ArrowRight' : 'ArrowLeft';
if (event.key === 'ArrowDown' || event.key === forward) next = (current + 1) % enabled.length;
else if (event.key === 'ArrowUp' || event.key === backward) next = (current - 1 + enabled.length) % enabled.length;
else if (event.key === 'Home') next = 0;
else if (event.key === 'End') next = enabled.length - 1;
else return; // Pressable supplies Enter and Space activation.
event.preventDefault();
const option = enabled[next];
rows.current.get(option.id)?.focus();
if (option.id !== value) onValueChange(option.id);
};
const element = host.current;
// The web View is a DOM element; native never enters this branch.
const node = element as unknown as HTMLElement | null;
node?.addEventListener('keydown', keydown);
return () => node?.removeEventListener('keydown', keydown);
}, [enabled, onValueChange, value]);
return (
<View ref={host} accessibilityRole="radiogroup" accessibilityLabel={label}>
<Text style={styles.heading}>{label}</Text>
{options.map((option) => (
<Pressable key={option.id}
ref={(node) => {
if (node) rows.current.set(option.id, node);
else rows.current.delete(option.id);
}}
accessibilityRole="radio" accessibilityLabel={option.label}
accessibilityHint={option.detail}
accessibilityState={{ checked: value === option.id, disabled: !!option.disabled }}
disabled={option.disabled}
{...(Platform.OS === 'web' ? { tabIndex: option.id === tabStop ? 0 as const : -1 as const,
'aria-checked': value === option.id, 'aria-disabled': !!option.disabled } : {})}
onFocus={() => { setFocusedId(option.id); setRowHasFocus(true); }}
onBlur={() => setRowHasFocus(false)}
onPress={() => { if (option.id !== value) onValueChange(option.id); }}
style={[styles.row, option.disabled && styles.disabled,
rowHasFocus && focusedId === option.id && styles.focused]}>
<RadioMark checked={value === option.id} />
<View style={styles.copy}>
<Text style={styles.label}>{option.label}{option.disabled ? ' (unavailable)' : ''}</Text>
{option.detail && <Text style={styles.detail}>{option.detail}</Text>}
</View>
</Pressable>
))}
{options.length === 0 && <Text>No options available.</Text>}
{options.length > 0 && !options.some((option) => option.id === value)
&& <Text accessibilityRole="alert">Choose an available option.</Text>}
</View>
);
}
const options: readonly Option[] = [
{ id: 'standard', label: 'Standard', detail: 'Delivery in 5–7 business days.' },
{ id: 'express', label: 'Express', detail: 'Delivery in 2 business days.' },
{ id: 'same-day', label: 'Same day', detail: 'Not available at this address.', disabled: true },
];
export function RadioExample() {
const [delivery, setDelivery] = useState('standard');
return (
<ScrollView contentContainerStyle={{ padding: 24, gap: 16 }}>
<RadioGroup label="Delivery speed" options={options} value={delivery} onValueChange={setDelivery} />
<Text>Saved value: {delivery}</Text>
<Pressable accessibilityRole="button" accessibilityLabel="Reset to standard delivery"
onPress={() => setDelivery('standard')} style={styles.row}>
<Text style={styles.label}>Reset delivery</Text>
</Pressable>
</ScrollView>
);
}
const styles = StyleSheet.create({
heading: { color: '#24163D', fontSize: 20, marginBottom: 12 },
row: { minHeight: 48, flexDirection: 'row', alignItems: 'center', gap: 12,
padding: 12, marginVertical: 4, borderWidth: 2, borderColor: '#C0B6CF', borderRadius: 12 },
focused: { borderColor: '#523587' },
disabled: { backgroundColor: '#EEEAF3' },
copy: { flex: 1, gap: 4 },
label: { color: '#24163D', fontSize: 16 },
detail: { color: '#51465F', fontSize: 14 },
ring: { width: 24, height: 24, borderWidth: 2, borderColor: '#523587',
borderRadius: 12, alignItems: 'center', justifyContent: 'center' },
dot: { width: 12, height: 12, borderRadius: 6, backgroundColor: '#523587' },
});Keep the form value authoritative
Supply unique, nonempty option IDs and store the ID rather than the label. The example has one value, not a Boolean state for each row. Pressing an enabled, unchecked row requests its ID once. The parent can accept the request, reject it, or update the value externally with Reset delivery. A rejected request leaves the old checked state and dot in place, even if keyboard focus moved to another row.
An unknown value leaves every radio unchecked and shows a prompt. An empty list shows an empty state. If an option becomes disabled while selected, the accepted value stays visible, but no touch or keyboard action can newly select that option. Decide in the form's validation layer whether an unavailable saved value blocks submission; silently choosing the first available option could commit a choice the user never made. All-disabled lists remain readable and have no keyboard entry point.
These are small lists. For hundreds of choices, use a searchable selection surface with a separate loading and empty-results model instead of rendering a long wall of radio rows. The tutorial leaves data fetching, persistence, and form validation to its parent.
Radio semantics, keyboard focus, and touch
The named group exposes radiogroup; each row exposes radio with checked and disabled states through the React Native accessibility API. The web branch supplies explicit aria-checked and aria-disabled attributes for React Native Web. A filled dot distinguishes the choice without relying on color. Descriptions are hints, and disabled rows include visible unavailable text. Keep essential descriptions visible because screen-reader hint verbosity varies.
Each whole row has a minimum forty-eight-point height, wrapping labels, and an explicit focus border. On web, one enabled row participates in Tab navigation. ArrowDown/ArrowUp and horizontal arrows move focus and request selection, wrapping past disabled rows; Home/End choose the endpoints. Horizontal meaning follows RTL. Enter/Space activate the focused Pressable, and Tab leaves the group without a trap. Native screen readers activate the same rows directly. This DOM keyboard listener is web-only; it does not implement a native hardware-key navigation layer.
The remembered focus ID determines the next web tab stop while the parent value determines checked state. That distinction matters when the parent rejects a change. Removed or newly disabled focus targets fall back to the accepted enabled option or the first enabled row. If live data removes a currently focused DOM node, your surrounding form should move focus to an appropriate surviving field; this compact example does not automatically focus after data replacement.
Animate the accepted selection
Each RadioMark follows its checked prop through one shared value. Reanimated drives opacity and scale without React updates per frame. A new accepted value retargets the spring from its current position; there is no animation-completion callback that could restore a stale form value. Reduced Motion sets the dot immediately instead of running the spring.
The useReducedMotion hook reads the startup preference. If your app must respond to preference changes without restarting, subscribe to AccessibilityInfo's reduceMotionChanged event and use that state to cancel motion and apply the final geometry. No gesture or UI-to-RN callback is needed here. If a future gesture worklet requests selection, cross threads with scheduleOnRN from react-native-worklets.
Check parent rejection, Reset during a spring, an invalid value, an empty/all-disabled list, RTL arrows, large text, and tabbing into and out of the group. Keep the form's error and required-field instructions outside optional tooltips.
What the paid component adds
Liquid Radio Group is a paid component with a traveling liquid dot, a connecting neck, a confined glow, and measured expanding descriptions. Retargeting snapshots the current geometry so a rapid change can interrupt the movement. It supports controlled and uncontrolled values, disabled rows or a disabled group, empty/unavailable copy, light and dark palettes, RTL, and select/reset handle methods.
Its README documents whole-row radio semantics, web keyboard navigation, screen-reader announcements, and an immediate final geometry under Reduce Motion. It uses Skia, Reanimated, and Worklets; no Gesture Handler wrapper is required. Initialize CanvasKit before importing it on web, use unique IDs, and choose whether the group or your screen owns scrolling. The component's vertical liquid rail and expanding descriptions go beyond this tutorial's independent dots.
Each published piece is reviewed on physical iPhone and Android devices with Expo Go before release. When integrating the source, check your own labels, palette contrast, parent validation, and screen-reader reading order in the surrounding form.
Explore the value selectors collection for related input patterns.
FAQ
Does React Native include a cross-platform radio group?
React Native supplies accessible primitives for building the group. Expo UI also offers an Android-native Jetpack Compose RadioButton. The custom example here uses Pressable rows to share visuals and behavior across iOS, Android, and web.
Can a disabled radio stay selected?
Yes, when the parent retains that saved value. Disabled means the user cannot newly choose it. Validate whether the value remains acceptable before submission rather than silently replacing it.
Should arrow keys change the selected value?
In this web radio pattern, arrows focus and request the next enabled choice. The parent still decides whether to accept it. Tab enters or leaves the group; it does not have to visit every radio.