Platform Differences
Platform.OS, Platform.select and .ios.tsx files handle two operating systems, plus how to decide which differences to keep.
Platform.select({ ios: valueA, android: valueB, native: valueC, default: valueD })One codebase runs on two operating systems that do not look or behave the same. React Native gives you three tools for the difference and leaves the judgement to you.
Platform
Platform.OS is 'ios' or 'android' (and 'web' when the same code runs through React Native for Web). Platform.Version is a number on Android, the API level, and a string on iOS. Platform.select(config) takes an object and returns the best match: the ios or android key first, then native, then default.
Platform.select({ ios: a, android: b }) and Platform.OS === 'ios' ? a : b are the same choice written two ways. Prefer select when you have three or more branches or want to spread a block of styles, and the ternary for one value. Note that select builds every value in the object before choosing one.
Separate files
When the difference is more than a value, split it. Metro resolves Chip.ios.tsx and Chip.android.tsx from import Chip from './Chip', with no extension in the import. Chip.native.tsx covers both platforms against a web build.
Flatten or respect
Flatten your brand: colours, copy, spacing, layout. Respect the platform where users have muscle memory: ripple against opacity feedback, system fonts, share and date pickers, and the fact that shadows are shadowColor and friends on iOS but elevation on Android.
Example
import { Platform, StyleSheet, Text, View } from "react-native";
export default function PlatformCard() {
return (
<View style={styles.card}>
<Text style={styles.text}>Running on {Platform.OS}</Text>
<Text style={styles.text}>OS version {String(Platform.Version)}</Text>
</View>
);
}
const styles = StyleSheet.create({
card: {
padding: 16,
borderRadius: 12,
backgroundColor: "#ffffff",
// There is no CSS box-shadow here. The same visual effect needs
// different properties on each platform, so spread a whole block.
...Platform.select({
ios: {
shadowColor: "#000000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15,
shadowRadius: 4,
},
android: { elevation: 3 },
default: {},
}),
},
text: {
fontSize: 15,
// The same choice in longhand, for a single value.
fontFamily: Platform.OS === "ios" ? "Helvetica Neue" : "sans-serif",
},
});
// On screen: a white card that casts a soft shadow on iOS and sits on an
// elevation on Android, reading "Running on ios" or "Running on android".
// Drop the Platform.select and the card has a shadow on exactly one of them.When to use it
- Give a card a shadow that actually appears on both iOS and Android.
- Match native padding or fonts so a field does not look wrong on one platform.
- Swap a whole component for a platform-specific implementation without touching its callers.
- Hide a feature on devices below the OS version that introduced it.
More examples
One import, two implementations
Use file splitting when the two versions differ in structure rather than in one value: different props on a native component, different feedback, different layout. Both files must export the same shape, because every caller imports ./Feedback and gets whichever one Metro resolved. A Feedback.native.tsx would serve both platforms and lose to a more specific .ios.tsx.
// --- Feedback.ios.tsx -------------------------------------------------
import { Pressable, StyleSheet, Text } from "react-native";
type FeedbackProps = {
label: string;
onPress: () => void;
};
// iOS convention: the control fades while held.
export default function Feedback({ label, onPress }: FeedbackProps) {
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [iosStyles.button, { opacity: pressed ? 0.6 : 1 }]}
>
<Text style={iosStyles.label}>{label}</Text>
</Pressable>
);
}
const iosStyles = StyleSheet.create({
button: { padding: 12, borderRadius: 10, backgroundColor: "#1d4ed8" },
label: { color: "#ffffff", fontSize: 16, textAlign: "center" },
});
// --- Feedback.android.tsx ---------------------------------------------
// Same props, same default export name. Android convention: a ripple.
//
// export default function Feedback({ label, onPress }: FeedbackProps) {
// return (
// <Pressable onPress={onPress} android_ripple={{ color: "#1e3a8a" }}
// style={androidStyles.button}>
// <Text style={androidStyles.label}>{label}</Text>
// </Pressable>
// );
// }
// --- HomeScreen.tsx ---------------------------------------------------
// No extension in the import. Metro picks the file for the platform it is
// bundling for, and the screen below never mentions either one.
//
// import Feedback from "./Feedback";Spreading a platform block into a shared style
The spread form is why Platform.select exists: it injects several properties at once into a style object, which a ternary cannot do neatly. The default key catches anything that is neither, including a web build. Keep brand values such as the radius and the grey outside the select, so only the genuinely platform-specific numbers vary.
import { Platform, StyleSheet, Text, TextInput, View } from "react-native";
type SearchFieldProps = {
placeholder: string;
};
export default function SearchField({ placeholder }: SearchFieldProps) {
return (
<View style={styles.wrapper}>
<Text style={styles.caption}>Search</Text>
<TextInput style={styles.input} placeholder={placeholder} />
</View>
);
}
const styles = StyleSheet.create({
wrapper: { padding: 16, gap: 6 },
caption: { fontSize: 13, color: "#71717a" },
input: {
borderRadius: 8,
backgroundColor: "#f4f4f5",
fontSize: 16,
// Native text fields have different intrinsic padding, so matching one
// number on both platforms produces two different-looking fields.
...Platform.select({
ios: { paddingVertical: 12, paddingHorizontal: 12 },
android: { paddingVertical: 6, paddingHorizontal: 10 },
default: { padding: 10 },
}),
},
});Gating on the OS version
Needed whenever a capability arrived in a specific OS release and you still support older devices. The comparison is the trap: Platform.Version is typed as a number on Android and a string on iOS, so an unguarded Platform.Version >= 31 is either a type error or a nonsense string comparison. Read these values once at module scope, because they cannot change while the app runs.
import { Platform, StyleSheet, Text, View } from "react-native";
// Platform.Version is the Android API level as a number, or the iOS version
// as a string. Narrow on Platform.OS before comparing anything.
const androidApiLevel = Platform.OS === "android" ? Number(Platform.Version) : 0;
// Android 12 (API 31) introduced system dynamic colours.
const hasDynamicColour = androidApiLevel >= 31;
export default function DeviceBanner() {
return (
<View style={styles.banner}>
<Text style={styles.line}>
{Platform.OS === "android"
? `Android API level ${androidApiLevel}`
: `iOS ${String(Platform.Version)}`}
</Text>
{hasDynamicColour ? (
<Text style={styles.line}>System dynamic colours available</Text>
) : null}
{/* Platform.isPad is iOS only and false elsewhere. */}
{Platform.isPad ? <Text style={styles.line}>Running on iPad</Text> : null}
</View>
);
}
const styles = StyleSheet.create({
banner: { padding: 16, gap: 4, backgroundColor: "#fafafa" },
line: { fontSize: 14, color: "#3f3f46" },
});
Discussion