Pick one in ten seconds
| Option | Platforms | Styling control | Native control | Use it for |
|---|---|---|---|---|
| expo-checkbox | iOS, Android, web | Colour only | No — drawn by the library | Almost everything |
| @react-native-community/checkbox | iOS, Android | Limited, platform-specific props | Yes on Android | When Android must look like Android |
| Pressable + icon | Everywhere | Total | No | Custom shapes, indeterminate, animation |
One more option people reach for and shouldn’t: a Switch. It is in core and it is tempting because it exists, but a switch means “this setting takes effect now” and a checkbox means “this will be included when you submit.” Using a switch inside a form makes people wonder whether they still need to press Save.
Option 1 — expo-checkbox, done properly
npx expo install expo-checkboximport { useState } from 'react';
import { Pressable, StyleSheet, Text } from 'react-native';
import Checkbox from 'expo-checkbox';
export default function TermsRow() {
const [accepted, setAccepted] = useState(false);
return (
// the WHOLE row is the target, not the 20pt box
<Pressable
style={styles.row}
onPress={() => setAccepted((v) => !v)}
accessibilityRole="checkbox"
accessibilityState={{ checked: accepted }}
accessibilityLabel="I accept the terms of service"
hitSlop={8}
>
<Checkbox
value={accepted}
onValueChange={setAccepted}
color={accepted ? '#fb923c' : undefined}
pointerEvents="none" // let the Pressable own the tap
/>
<Text style={styles.label}>I accept the terms of service</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
minHeight: 44, // Apple's minimum touch target
paddingVertical: 8,
},
label: { color: 'rgba(255,255,255,0.85)', fontSize: 15, flexShrink: 1 },
});The pointerEvents="none" on the checkbox is the part people leave out. Without it you have two overlapping touch targets, and a tap that lands on the box fires both onValueChange and the wrapper’s onPress — the value flips twice and the checkbox appears not to work at all. Let one component own the gesture.
Option 2 — roll your own
Once your design has a rounded square, a brand tick, or a third indeterminate state for a “select all” header, the library stops helping. A custom checkbox is about twenty lines and gives you every property you need.
import { Pressable, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
type Props = {
label: string;
checked: boolean;
indeterminate?: boolean;
onChange: (next: boolean) => void;
};
export function Checkbox({ label, checked, indeterminate, onChange }: Props) {
const on = checked || indeterminate;
return (
<Pressable
onPress={() => onChange(!checked)}
accessibilityRole="checkbox"
accessibilityState={{ checked: indeterminate ? 'mixed' : checked }}
accessibilityLabel={label}
style={{ flexDirection: 'row', alignItems: 'center', gap: 12, minHeight: 44 }}
hitSlop={8}
>
<View
style={{
width: 22, height: 22, borderRadius: 6,
borderWidth: on ? 0 : 1.5,
borderColor: 'rgba(255,255,255,0.3)',
backgroundColor: on ? '#fb923c' : 'transparent',
alignItems: 'center', justifyContent: 'center',
}}
>
{on && (
<Ionicons
name={indeterminate ? 'remove' : 'checkmark'}
size={15}
color="#1c1c1c"
/>
)}
</View>
<Text style={{ color: 'rgba(255,255,255,0.85)', fontSize: 15, flexShrink: 1 }}>
{label}
</Text>
</Pressable>
);
}accessibilityState={{ checked: 'mixed' }} is the correct value for indeterminate, and it is the one thing you cannot express with any of the libraries. If you have a select-all header, that alone is the reason to write your own.
Wiring it into a form
register() is a DOM API and there is no DOM here, so it does nothing for a checkbox. Every non-text React Native input goes through Controller:
import { Controller, useForm } from 'react-hook-form';
const { control, handleSubmit, formState: { errors } } = useForm({
defaultValues: { acceptedTerms: false },
});
<Controller
control={control}
name="acceptedTerms"
rules={{ required: 'You must accept the terms to continue' }}
render={({ field: { value, onChange } }) => (
<Checkbox
label="I accept the terms of service"
checked={value}
onChange={onChange}
/>
)}
/>
{errors.acceptedTerms && (
<Text style={{ color: '#f87171', fontSize: 13 }}>
{errors.acceptedTerms.message}
</Text>
)}Note required works on a boolean the way you’d hope — false fails validation. The full pattern is in the form validation guide.
The traps, in the order you’ll hit them
CheckBoxis not exported fromreact-native. You’re following a pre-2021 answer. It was removed.- Tapping does nothing. The value is controlled and nothing writes it back, or the box and its wrapper both handle the tap and cancel each other out.
- It’s hard to hit. A 20pt box is under half the 44pt minimum. Make the row the target and add
hitSlop. - It disappears in dark mode.
expo-checkboxdefaults to a light-scheme border. Setcolorexplicitly for both states. - A list of checkboxes re-renders on every tap.You’re keeping the checked set in one parent state object and passing a new callback to each row. Memoize the row and pass a stable handler — see the FlatList guide.
- The label wraps off-screen. A long label in a row needs
flexShrink: 1, otherwise it pushes past the edge instead of wrapping.
The shortcut: generate the form, keep the taste
Touch targets, accessibility roles, the Controller wiring, the two-tap bug — all of it is mechanical, and all of it is identical in every app that has a signup screen. What isn’t mechanical is what you’re asking people to agree to and how much friction that’s worth.
Describe the screen in ShipNative — “a signup form with email, password, and a required terms checkbox” — and it generates the form, the validation, and accessible controls, then runs it on your phone so you can check the tap targets with a real thumb.
Build it free
Describe your app in one sentence and have it running on your phone in minutes at shipnative.dev. No credit card, full React Native source export whenever you want it.