Skip to content
All components

Free reference

React Native Radio Button

React Native has no radio button component, so you build one: a RadioGroup that owns the selected value and passes it down through context, and a RadioOption that draws a ring, springs an inner dot with Reanimated, and sets accessibilityRole "radio" with accessibilityState checked.

Installation

The ring, the dot and the tap target are core React Native. The only dependency is Reanimated, for the spring on the dot. Reanimated 4 runs worklets through react-native-worklets, so install both.

npx expo install react-native-reanimated react-native-worklets

Expo SDK 54 wires up the Babel plugin through babel-preset-expo. Restart the bundler with a cleared cache afterwards.

Usage

One file. RadioGroup holds the selected value, RadioOption renders one row, and the demo at the bottom wires up three options.

import {
  createContext,
  useContext,
  useMemo,
  useState,
  type ReactNode,
} from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import Animated, {
  useAnimatedStyle,
  withSpring,
} from 'react-native-reanimated';

const SPRING = { damping: 15, stiffness: 260, mass: 0.6 };

type RadioGroupContextValue = {
  value: string;
  select: (next: string) => void;
};

const RadioGroupContext = createContext<RadioGroupContextValue | null>(null);

function useRadioGroup(): RadioGroupContextValue {
  const group = useContext(RadioGroupContext);
  if (!group) {
    throw new Error('<RadioOption /> must be rendered inside a <RadioGroup />.');
  }
  return group;
}

type RadioGroupProps = {
  /** The value of the option that is currently selected. */
  value: string;
  onValueChange: (next: string) => void;
  /** Announced once, before the options. */
  label: string;
  children: ReactNode;
};

export function RadioGroup({
  value,
  onValueChange,
  label,
  children,
}: RadioGroupProps) {
  const group = useMemo(
    () => ({ value, select: onValueChange }),
    [value, onValueChange],
  );

  return (
    <RadioGroupContext.Provider value={group}>
      <View
        accessibilityRole="radiogroup"
        accessibilityLabel={label}
        style={styles.group}
      >
        {children}
      </View>
    </RadioGroupContext.Provider>
  );
}

type RadioOptionProps = {
  value: string;
  label: string;
  hint?: string;
  disabled?: boolean;
};

export function RadioOption({
  value,
  label,
  hint,
  disabled = false,
}: RadioOptionProps) {
  const group = useRadioGroup();
  const selected = group.value === value;

  const dotStyle = useAnimatedStyle(
    () => ({ transform: [{ scale: withSpring(selected ? 1 : 0, SPRING) }] }),
    [selected],
  );

  return (
    <Pressable
      accessibilityRole="radio"
      accessibilityState={{ checked: selected, disabled }}
      accessibilityLabel={hint ? `${label}, ${hint}` : label}
      disabled={disabled}
      onPress={() => group.select(value)}
      style={({ pressed }) => [
        styles.row,
        pressed && !disabled && styles.rowPressed,
        disabled && styles.rowDisabled,
      ]}
    >
      <View style={[styles.ring, selected && styles.ringSelected]}>
        <Animated.View style={[styles.dot, dotStyle]} />
      </View>

      <View style={styles.copy}>
        <Text style={styles.label}>{label}</Text>
        {hint ? <Text style={styles.hint}>{hint}</Text> : null}
      </View>
    </Pressable>
  );
}

/* ---------- Usage ---------- */

type Option = {
  value: string;
  label: string;
  hint: string;
  disabled?: boolean;
};

const SPEEDS: Option[] = [
  { value: 'standard', label: 'Standard', hint: '3 to 5 business days' },
  { value: 'express', label: 'Express', hint: 'Tomorrow before 6pm' },
  {
    value: 'pickup',
    label: 'Store pickup',
    hint: 'No store near you',
    disabled: true,
  },
];

export default function DeliveryPicker() {
  const [speed, setSpeed] = useState('standard');

  return (
    <View style={styles.screen}>
      <Text style={styles.title}>Delivery</Text>

      <RadioGroup value={speed} onValueChange={setSpeed} label="Delivery speed">
        {SPEEDS.map((option) => (
          <RadioOption
            key={option.value}
            value={option.value}
            label={option.label}
            hint={option.hint}
            disabled={option.disabled}
          />
        ))}
      </RadioGroup>
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, gap: 12, padding: 20, backgroundColor: '#fff' },
  title: {
    fontSize: 13,
    fontWeight: '600',
    letterSpacing: 0.6,
    color: '#8a8a8a',
    textTransform: 'uppercase',
  },
  group: { gap: 4 },
  row: {
    flexDirection: 'row',
    alignItems: 'center',
    gap: 12,
    minHeight: 48,
    paddingVertical: 8,
    paddingHorizontal: 4,
    borderRadius: 12,
  },
  rowPressed: { backgroundColor: '#f3f3f3' },
  rowDisabled: { opacity: 0.4 },
  ring: {
    width: 22,
    height: 22,
    borderRadius: 11,
    borderWidth: 2,
    borderColor: '#c9c9c9',
    alignItems: 'center',
    justifyContent: 'center',
  },
  ringSelected: { borderColor: '#111' },
  dot: { width: 12, height: 12, borderRadius: 6, backgroundColor: '#111' },
  copy: { flex: 1 },
  label: { fontSize: 16, fontWeight: '500', color: '#111' },
  hint: { fontSize: 13, color: '#8a8a8a', marginTop: 2 },
});

How it works

React Native ships Switch and nothing for exclusive choice, on either platform. Drawing it is the easy half: a 22pt ring with a 12pt dot inside. The half that decides your architecture is that "radio" means exactly one option is on, and exclusivity belongs to the set, not to any single option.

So the selected value lives on the group and travels down through context. Each option declares only its own value and derives selected by comparing. Add an option, reorder them, load them from an API: nothing else changes, because there is only ever one piece of state to be wrong.

The dot animates with a single scale spring. useAnimatedStyle re-runs when selected flips, and withSpring starts from wherever the dot currently is, so hammering between two options never snaps. A React render starts the spring, but the frames after it interpolate on the UI thread, so JS work cannot stutter the motion.

Gotchas

Do not build it from N booleans

The tempting version is useState(false) per option, each tap turning the others off. Every tap then writes to k pieces of state, and any path that misses one leaves two dots filled or none. A single value: string makes those states unrepresentable, and it is the shape your form library already wants.

A 22pt circle is not a tap target

iOS asks for 44pt, Android for 48dp, and the ring on its own is half of that. Make the whole row the Pressable and give it minHeight: 48, which also matches how people actually tap, at the label rather than the circle. Skip hitSlop: stacked rows sit a few points apart, so padded targets overlap and an edge tap lands on the neighbour.

Screen readers read the state, not your dot

A filled circle means nothing to VoiceOver or TalkBack. accessibilityRole="radio" plus accessibilityState={{ checked }} is what announces it as selected. Role "button" plus a visual checkmark leaves a blind user no way to know what is chosen. accessibilityRole="radiogroup" tells TalkBack the rows are one set, but iOS only focuses containers that set accessible, so VoiceOver skips that label: keep the group name in visible text above the rows.

Animate scale, not size

Springing width, height or borderWidth runs a layout pass on every frame, and on Android sub-pixel rounding makes the circle jitter as it grows. A transform is composited instead, so the dot stays round and stays cheap with a dozen rows on screen.

Does React Native have a built-in radio button?

No. React Native provides Switch for on and off, and no radio button component on either iOS or Android. You build one from two nested Views, a Pressable row and a single piece of state, under a hundred lines including the styles. Set accessibilityRole="radio" on each option and "radiogroup" on the container, and screen readers announce it as a radio with its selected state.

Should I use a library like react-native-paper instead?

Use one if you already ship that library and want its theming everywhere. Adding a whole UI kit only to get a radio button means inheriting its theme system, its upgrade cadence and its styling limits for a component you could own. A hand-rolled React Native radio button needs Reanimated only for the spring, so a dot that simply toggles has no dependencies at all.

How do I let the user clear their choice?

Radio buttons are exclusive, not optional: once one is selected there is no gesture to deselect it, and users do not look for one. If an empty answer is valid, add an explicit "No preference" option instead. To start with nothing selected, type the value as string | null and pass null: no option matches, so no dot is filled.

How do I type the value as a union instead of a string?

Keep the components on string and put the exact type on your own state, where it earns something. Declare type Speed = 'standard' | 'express', hold it with useState<Speed>('standard'), and narrow at the single boundary: onValueChange={(next) => setSpeed(next as Speed)}. Your switch statements and API calls get exhaustiveness checks, and RadioGroup stays a plain non-generic component.

Depends onreact-native-reanimatedreact-native-worklets

Every component on this page is free to copy. Motionary sells the polished, production versions over at the catalog.