Rule one: it is controlled
import { useState } from 'react';
import { Switch } from 'react-native';
export default function NotificationsToggle() {
const [enabled, setEnabled] = useState(false);
return (
<Switch
value={enabled}
onValueChange={setEnabled}
trackColor={{ false: '#3a3a3a', true: '#fb923c' }}
thumbColor="#ffffff"
ios_backgroundColor="#3a3a3a"
/>
);
}The switch shows whatever value says. Tapping it calls onValueChange with the new boolean and nothing more. Forget to store that boolean, or store it somewhere the switch does not read, and the knob slides across and slides straight back. That snap-back is the single most searched Switch problem, and the fix is always the same: make sure value comes from the state that onValueChange updates.
Rule two: colours work differently per platform
| Prop | iOS | Android |
|---|---|---|
value | Required to show state | Required to show state |
trackColor.true | Track colour when on | Track colour when on |
trackColor.false | Outline only when off | Track colour when off |
ios_backgroundColor | Fill colour when off | Ignored |
thumbColor | Knob colour | Knob colour |
width / height | Ignored | Ignored |
The row that trips people up is trackColor.false. On Android it fills the track when the switch is off. On iOS it only tints the thin border, so a dark-mode app that sets it and nothing else gets a pale grey iOS switch sitting on a black screen. Set ios_backgroundColor to the same colour and both platforms match. Even then the two switches will not look identical, because each platform draws its own control. If pixel-identical matters, you are building a custom toggle.
Rule three: size is fixed
width and height on a Switch do nothing. The only lever is a transform:
<Switch
value={enabled}
onValueChange={setEnabled}
style={{ transform: [{ scale: 0.8 }], marginRight: -6 }}
/>A transform changes how the switch is painted, not how much space it takes. The layout still reserves the full-size box, so a shrunk switch leaves a gap and an enlarged one overlaps whatever sits next to it. The negative margin above pulls the row edge back in. Keep scaling modest. Below about 0.75 the touch target gets too small to hit reliably.
A real settings row: tappable label, accessible, optimistic
In a settings screen, users tap the words, not the tiny switch. The whole row should toggle, screen readers should hear one control with a name and a state, and the switch should move instantly even though the save goes to a server.
import { useState } from 'react';
import { Alert, Pressable, StyleSheet, Switch, Text, View } from 'react-native';
export function SettingRow({ label, hint, initial, save }) {
const [on, setOn] = useState(initial);
const toggle = async (next: boolean) => {
setOn(next); // move the switch now
try {
await save(next);
} catch {
setOn(!next); // put it back if the save failed
Alert.alert('Could not save', 'Check your connection and try again.');
}
};
return (
<Pressable
onPress={() => toggle(!on)}
accessibilityRole="switch"
accessibilityLabel={label}
accessibilityHint={hint}
accessibilityState={{ checked: on }}
style={styles.row}
>
<View style={styles.text}>
<Text style={styles.label}>{label}</Text>
{hint ? <Text style={styles.hint}>{hint}</Text> : null}
</View>
<Switch
value={on}
onValueChange={toggle}
importantForAccessibility="no-hide-descendants"
accessibilityElementsHidden
trackColor={{ false: '#3a3a3a', true: '#fb923c' }}
ios_backgroundColor="#3a3a3a"
thumbColor="#ffffff"
/>
</Pressable>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 14, paddingHorizontal: 16 },
text: { flex: 1, paddingRight: 12 },
label: { color: '#fff', fontSize: 16 },
hint: { color: '#8a8a8a', fontSize: 13, marginTop: 2 },
});The row carries the switch role and the checked state, and the inner Switch is hidden from the screen reader, so VoiceOver and TalkBack announce one control instead of a label and a separate unlabelled toggle. The optimistic update means the switch never feels slow, and the rollback means it never lies about what was saved. If the setting is stored on the device instead, the same row works with a synchronous save to MMKV or AsyncStorage.
When not to use a switch
A switch promises that the change has already happened. That makes it right for settings screens and wrong inside forms with a Save or Submit button, where it leaves people unsure whether their choice is live yet. For “choose now, apply later”, use a checkbox. For choices with more than two states, such as light, dark, and system theme, use a segmented control or a short list, not two switches that can contradict each other.
Settings screens are the kind of screen that looks finished in a design file and turns out to be wrong in the hand. Describe yours, “a settings screen with toggles for notifications, weekly email, and haptics”, and ShipNative builds it as real React Native that runs on your phone, so you can tap the rows and hear them with VoiceOver before shipping.