Platform-Specific Styles
Write once, look native everywhere
Table of Contents
🎯 Learning Objectives
- Understand the key visual differences between iOS and Android
- Use the
Platformmodule to detect and adapt to platforms - Apply
Platform.select()for conditional styling - Create platform-specific file variants when needed
- Implement cross-platform shadows that work on both systems
- Handle platform-specific fonts and typography
- Build components that feel native on each platform
Introduction
React Native's promise is "learn once, write anywhere" — but that doesn't mean your app should look identical on iOS and Android. Users expect apps to feel native to their platform. iOS users expect iOS conventions; Android users expect Material Design patterns.
The challenge is finding the right balance: share as much code as possible while respecting platform conventions where they matter. This lesson teaches you how to write styles that adapt intelligently to each platform.
🎨 The Platform Style Philosophy
Your goal isn't pixel-perfect matching between platforms. It's creating an experience that feels right on each platform. Sometimes that means different shadows, fonts, or spacing. The user shouldn't notice your platform adaptation — they should just feel at home.
What We'll Cover
This lesson focuses on the styling aspects of platform differences. We'll cover:
- Detecting the current platform
- Applying platform-specific styles
- Handling shadows (the biggest platform difference)
- Working with platform fonts
- Creating components that adapt to each platform
flowchart TD
subgraph Code["Shared Codebase"]
C["Component Logic"]
S["Base Styles"]
end
subgraph Platform["Platform Detection"]
P{"Platform.OS"}
end
subgraph Output["Platform Output"]
iOS["iOS Native Look"]
Android["Android Native Look"]
end
Code --> Platform
Platform -->|"ios"| iOS
Platform -->|"android"| Android
style Code fill:#e3f2fd
style Platform fill:#fff3e0
style iOS fill:#f5f5f5
style Android fill:#e8f5e9
iOS vs Android Visual Differences
Before diving into code, let's understand what actually differs between platforms. This knowledge helps you make informed decisions about when to customize.
Key Visual Differences
| Aspect | iOS | Android |
|---|---|---|
| Shadows | shadowColor, shadowOffset, shadowOpacity, shadowRadius | elevation (single number) |
| Default Font | San Francisco | Roboto |
| Touch Feedback | Opacity change | Ripple effect |
| Navigation | Slide from right, back swipe | Fade/slide up, back button |
| Status Bar | Part of safe area, light/dark | Can be translucent, colored |
| Button Style | Often borderless, text-based | Contained, elevated, or outlined |
| Border Radius | Often larger, more rounded | Varies, Material uses 4dp default |
💡 When to Customize
Always customize: Shadows, touch feedback, status bar
Sometimes customize: Fonts, button styles, border radius
Rarely customize: Colors, spacing, layout structure
The Platform Module
React Native provides the Platform module for detecting and adapting to the current platform.
import { Platform } from 'react-native';
// Detect current platform
console.log(Platform.OS); // 'ios' | 'android' | 'web' | 'windows' | 'macos'
// Check platform version
console.log(Platform.Version);
// iOS: '16.0' (string)
// Android: 33 (number, API level)
// Boolean checks
const isIOS = Platform.OS === 'ios';
const isAndroid = Platform.OS === 'android';
// Check if running on TV
const isTV = Platform.isTV;
// Check if running in test environment
const isTesting = Platform.isTesting;
Basic Platform Conditionals
import { Platform, StyleSheet } from 'react-native';
const styles = StyleSheet.create({
container: {
padding: 16,
// Platform-specific padding
paddingTop: Platform.OS === 'ios' ? 20 : 0,
},
text: {
fontSize: 16,
// Platform-specific font weight
fontWeight: Platform.OS === 'ios' ? '600' : '500',
},
button: {
// Platform-specific border radius
borderRadius: Platform.OS === 'ios' ? 10 : 4,
},
});
Checking Platform Version
Sometimes you need to check the OS version for specific features or workarounds:
import { Platform } from 'react-native';
// iOS version check (Version is a string like '16.0')
const isIOS16OrNewer = Platform.OS === 'ios' &&
parseInt(Platform.Version, 10) >= 16;
// Android API level check (Version is a number like 33)
const isAndroid13OrNewer = Platform.OS === 'android' &&
Platform.Version >= 33;
// Feature detection pattern
function canUseBlurView() {
if (Platform.OS === 'ios') {
return true; // iOS has native blur support
}
if (Platform.OS === 'android' && Platform.Version >= 31) {
return true; // Android 12+ has blur support
}
return false;
}
⚠️ Version Comparison Gotcha
iOS Platform.Version is a string ('16.0'), while Android is a number (33). Always parse iOS versions before comparing, or use a utility function.
Utility for Safe Version Comparison
// utils/platform.ts
import { Platform } from 'react-native';
export function getIOSVersion(): number {
if (Platform.OS !== 'ios') return 0;
return parseFloat(Platform.Version);
}
export function getAndroidAPILevel(): number {
if (Platform.OS !== 'android') return 0;
return Platform.Version;
}
export function isIOSVersionAtLeast(version: number): boolean {
return Platform.OS === 'ios' && getIOSVersion() >= version;
}
export function isAndroidAPIAtLeast(apiLevel: number): boolean {
return Platform.OS === 'android' && getAndroidAPILevel() >= apiLevel;
}
Platform.select()
Platform.select() is the cleanest way to define platform-specific values. It takes an object with platform keys and returns the value for the current platform.
import { Platform, StyleSheet } from 'react-native';
const styles = StyleSheet.create({
container: {
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
},
android: {
elevation: 4,
},
default: {
// Web or other platforms
boxShadow: '0 2px 4px rgba(0,0,0,0.25)',
},
}),
},
});
Platform.select() for Any Value
It's not just for styles — use it for any platform-specific value:
// Platform-specific component import
const ScrollIndicator = Platform.select({
ios: () => require('./ScrollIndicatorIOS').default,
android: () => require('./ScrollIndicatorAndroid').default,
})();
// Platform-specific config
const config = Platform.select({
ios: {
animationType: 'slide',
hapticFeedback: true,
},
android: {
animationType: 'fade',
hapticFeedback: false,
},
});
// Platform-specific messages
const permissionMessage = Platform.select({
ios: 'Go to Settings > Privacy > Camera',
android: 'Go to Settings > Apps > YourApp > Permissions',
});
Using with StyleSheet
A common pattern is spreading Platform.select() results into style objects:
const styles = StyleSheet.create({
card: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 16,
// Spread platform-specific shadow
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
},
android: {
elevation: 3,
},
}),
},
header: {
fontSize: 24,
fontWeight: Platform.select({
ios: '700',
android: 'bold', // Android prefers 'bold' over numeric
}),
fontFamily: Platform.select({
ios: 'System',
android: 'Roboto',
}),
},
});
Nested Platform.select()
For complex components, you might need multiple platform-specific properties:
const styles = StyleSheet.create({
button: {
paddingVertical: Platform.select({ ios: 12, android: 10 }),
paddingHorizontal: Platform.select({ ios: 20, android: 16 }),
borderRadius: Platform.select({ ios: 10, android: 4 }),
backgroundColor: Platform.select({
ios: '#007AFF',
android: '#6200EE',
}),
},
});
✅ Best Practice: Centralize Platform Logic
Rather than scattering Platform.select() calls throughout your code, consider creating a theme or constants file that encapsulates platform differences:
// theme/shadows.ts
export const shadows = {
small: Platform.select({
ios: { shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.1, shadowRadius: 2 },
android: { elevation: 2 },
}),
medium: Platform.select({
ios: { shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.15, shadowRadius: 4 },
android: { elevation: 4 },
}),
};
Platform-Specific Files
When platform differences are substantial, React Native supports splitting code into platform-specific files. The bundler automatically picks the right file based on the platform.
File Naming Convention
// File structure:
components/
├── Button.tsx # Shared/fallback (optional)
├── Button.ios.tsx # iOS-specific
├── Button.android.tsx # Android-specific
└── Button.web.tsx # Web-specific (if using Expo web)
// Importing - just use the base name
import { Button } from './components/Button';
// React Native automatically resolves to:
// - Button.ios.tsx on iOS
// - Button.android.tsx on Android
// - Button.tsx as fallback
When to Use Platform Files
✅ Use Platform Files When:
- Components have significantly different implementations
- Using platform-specific native APIs
- Component logic (not just styles) differs substantially
- File would have excessive
Platform.select()calls
⚠️ Avoid Platform Files When:
- Only styles differ (use
Platform.select()instead) - Differences are minor (leads to code duplication)
- You want to share most logic between platforms
Example: Platform-Specific Button
// Button.ios.tsx
import { Pressable, Text, StyleSheet } from 'react-native';
interface ButtonProps {
title: string;
onPress: () => void;
variant?: 'primary' | 'secondary';
}
export function Button({ title, onPress, variant = 'primary' }: ButtonProps) {
return (
<Pressable
style={({ pressed }) => [
styles.button,
variant === 'secondary' && styles.secondary,
pressed && styles.pressed,
]}
onPress={onPress}
>
<Text style={[
styles.text,
variant === 'secondary' && styles.secondaryText,
]}>
{title}
</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
button: {
backgroundColor: '#007AFF',
paddingVertical: 12,
paddingHorizontal: 20,
borderRadius: 10,
alignItems: 'center',
},
secondary: {
backgroundColor: 'transparent',
},
pressed: {
opacity: 0.7,
},
text: {
color: '#fff',
fontSize: 17,
fontWeight: '600',
},
secondaryText: {
color: '#007AFF',
},
});
// Button.android.tsx
import { Pressable, Text, StyleSheet } from 'react-native';
interface ButtonProps {
title: string;
onPress: () => void;
variant?: 'primary' | 'secondary';
}
export function Button({ title, onPress, variant = 'primary' }: ButtonProps) {
return (
<Pressable
style={({ pressed }) => [
styles.button,
variant === 'secondary' && styles.secondary,
]}
android_ripple={{
color: variant === 'secondary' ? '#6200EE20' : '#ffffff40',
}}
onPress={onPress}
>
<Text style={[
styles.text,
variant === 'secondary' && styles.secondaryText,
]}>
{title.toUpperCase()}
</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
button: {
backgroundColor: '#6200EE',
paddingVertical: 10,
paddingHorizontal: 16,
borderRadius: 4,
alignItems: 'center',
elevation: 2,
},
secondary: {
backgroundColor: 'transparent',
elevation: 0,
},
text: {
color: '#fff',
fontSize: 14,
fontWeight: '500',
letterSpacing: 1,
},
secondaryText: {
color: '#6200EE',
},
});
Sharing Types Across Platform Files
// Button.types.ts (shared types)
export interface ButtonProps {
title: string;
onPress: () => void;
variant?: 'primary' | 'secondary';
disabled?: boolean;
}
// Button.ios.tsx
import { ButtonProps } from './Button.types';
export function Button({ title, onPress, variant, disabled }: ButtonProps) {
// iOS implementation
}
// Button.android.tsx
import { ButtonProps } from './Button.types';
export function Button({ title, onPress, variant, disabled }: ButtonProps) {
// Android implementation
}
Cross-Platform Shadows
Shadows are the most common platform styling challenge. iOS and Android handle them completely differently.
iOS Shadow Properties
// iOS uses four properties to define shadows
const iosShadow = {
shadowColor: '#000000', // Shadow color
shadowOffset: { width: 0, height: 2 }, // X and Y offset
shadowOpacity: 0.25, // 0 to 1
shadowRadius: 4, // Blur radius
};
Android Elevation
// Android uses a single elevation property
const androidShadow = {
elevation: 4, // Higher = larger shadow
// Note: element must have backgroundColor for elevation to show!
};
⚠️ Android Shadow Gotcha
Android's elevation only works on elements with a backgroundColor. Transparent or missing backgrounds won't show shadows!
Universal Shadow Utility
Create a utility that generates cross-platform shadow styles:
// utils/shadows.ts
import { Platform, ViewStyle } from 'react-native';
interface ShadowOptions {
color?: string;
offsetX?: number;
offsetY?: number;
opacity?: number;
radius?: number;
elevation?: number;
}
export function createShadow({
color = '#000000',
offsetX = 0,
offsetY = 2,
opacity = 0.25,
radius = 4,
elevation = 4,
}: ShadowOptions = {}): ViewStyle {
return Platform.select({
ios: {
shadowColor: color,
shadowOffset: { width: offsetX, height: offsetY },
shadowOpacity: opacity,
shadowRadius: radius,
},
android: {
elevation: elevation,
},
default: {},
}) as ViewStyle;
}
// Usage
const styles = StyleSheet.create({
card: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 16,
...createShadow({ elevation: 4, radius: 8, opacity: 0.15 }),
},
});
Shadow Presets
// theme/shadows.ts
import { Platform, ViewStyle } from 'react-native';
type ShadowPreset = ViewStyle;
export const shadows: Record<string, ShadowPreset> = {
none: {},
sm: Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
},
android: {
elevation: 1,
},
}) as ShadowPreset,
md: Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15,
shadowRadius: 4,
},
android: {
elevation: 3,
},
}) as ShadowPreset,
lg: Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
},
android: {
elevation: 6,
},
}) as ShadowPreset,
xl: Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.25,
shadowRadius: 16,
},
android: {
elevation: 12,
},
}) as ShadowPreset,
};
// Usage
const styles = StyleSheet.create({
card: {
backgroundColor: '#fff',
...shadows.md,
},
modal: {
backgroundColor: '#fff',
...shadows.xl,
},
});
Platform Fonts
iOS and Android have different system fonts and handle font weights differently.
System Fonts
| Platform | System Font | How to Use |
|---|---|---|
| iOS | San Francisco (SF Pro) | fontFamily: 'System' or omit |
| Android | Roboto | fontFamily: 'Roboto' or omit |
Font Weight Differences
// iOS supports numeric font weights
const iosWeights = {
thin: '100',
extraLight: '200',
light: '300',
regular: '400',
medium: '500',
semiBold: '600',
bold: '700',
extraBold: '800',
black: '900',
};
// Android has limited support - use these for best results
const androidWeights = {
normal: 'normal', // 400
bold: 'bold', // 700
// For other weights, use fontFamily variants
};
// Cross-platform font weight
const fontWeight = Platform.select({
ios: '600',
android: 'bold', // Closest to 600 on Android
});
Typography Scale
// theme/typography.ts
import { Platform, TextStyle } from 'react-native';
type TypographyVariant = TextStyle;
export const typography: Record<string, TypographyVariant> = {
h1: {
fontSize: 32,
fontWeight: Platform.select({ ios: '700', android: 'bold' }),
letterSpacing: Platform.select({ ios: 0.35, android: 0 }),
lineHeight: 40,
},
h2: {
fontSize: 24,
fontWeight: Platform.select({ ios: '600', android: 'bold' }),
letterSpacing: Platform.select({ ios: 0.35, android: 0 }),
lineHeight: 32,
},
h3: {
fontSize: 20,
fontWeight: Platform.select({ ios: '600', android: 'bold' }),
lineHeight: 28,
},
body: {
fontSize: 16,
fontWeight: '400',
lineHeight: 24,
},
bodySmall: {
fontSize: 14,
fontWeight: '400',
lineHeight: 20,
},
caption: {
fontSize: 12,
fontWeight: '400',
lineHeight: 16,
letterSpacing: 0.4,
},
button: {
fontSize: Platform.select({ ios: 17, android: 14 }),
fontWeight: Platform.select({ ios: '600', android: '500' }),
letterSpacing: Platform.select({ ios: 0, android: 1.25 }),
textTransform: Platform.select({ ios: 'none', android: 'uppercase' }),
},
};
// Usage
const styles = StyleSheet.create({
title: {
...typography.h1,
color: '#1a1a1a',
},
paragraph: {
...typography.body,
color: '#666',
},
});
Custom Fonts
When using custom fonts, you may need platform-specific font family names:
// Custom font loading with Expo
import { useFonts } from 'expo-font';
function App() {
const [fontsLoaded] = useFonts({
'Inter-Regular': require('./assets/fonts/Inter-Regular.ttf'),
'Inter-Medium': require('./assets/fonts/Inter-Medium.ttf'),
'Inter-Bold': require('./assets/fonts/Inter-Bold.ttf'),
});
if (!fontsLoaded) return null;
return <MainApp />;
}
// Using custom fonts
const styles = StyleSheet.create({
text: {
fontFamily: 'Inter-Regular',
},
textMedium: {
fontFamily: 'Inter-Medium',
},
textBold: {
fontFamily: 'Inter-Bold',
},
});
Status Bar Styling
The status bar (showing time, battery, signal) behaves differently on each platform and requires platform-specific handling.
Basic StatusBar Usage
import { StatusBar, Platform, View } from 'react-native';
function Screen() {
return (
<View style={styles.container}>
<StatusBar
barStyle="dark-content" // 'dark-content' | 'light-content'
backgroundColor="#ffffff" // Android only
translucent={false} // Android only
/>
{/* Screen content */}
</View>
);
}
Platform Differences
iOS
barStyle: Changes icon colors- Background is always transparent
- Content renders behind status bar
- Use SafeAreaView for proper insets
Android
barStyle: Changes icon colorsbackgroundColor: Sets bar colortranslucent: Content behind bar- Requires explicit height handling
Dynamic Status Bar
import { StatusBar, Platform, useColorScheme } from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
function DarkScreen() {
// Change status bar when screen is focused
useFocusEffect(() => {
StatusBar.setBarStyle('light-content');
if (Platform.OS === 'android') {
StatusBar.setBackgroundColor('#1a1a1a');
}
return () => {
StatusBar.setBarStyle('dark-content');
if (Platform.OS === 'android') {
StatusBar.setBackgroundColor('#ffffff');
}
};
});
return (
<View style={styles.darkContainer}>
{/* Dark content */}
</View>
);
}
// Adaptive to system theme
function AdaptiveScreen() {
const colorScheme = useColorScheme();
const isDark = colorScheme === 'dark';
return (
<View style={[styles.container, isDark && styles.darkContainer]}>
<StatusBar barStyle={isDark ? 'light-content' : 'dark-content'} />
{/* Content */}
</View>
);
}
Expo StatusBar Component
// Using Expo's StatusBar component (recommended)
import { StatusBar } from 'expo-status-bar';
function App() {
return (
<>
<StatusBar style="auto" />
{/* 'auto' adapts to content, 'light', 'dark', 'inverted' */}
<MainContent />
</>
);
}
Touch Feedback Patterns
iOS and Android have distinctly different touch feedback conventions. iOS uses opacity changes; Android uses ripple effects.
Pressable with Platform Feedback
import { Pressable, Platform, StyleSheet } from 'react-native';
function PlatformButton({ onPress, children }) {
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [
styles.button,
// iOS: opacity feedback
Platform.OS === 'ios' && pressed && styles.pressedIOS,
]}
// Android: ripple feedback
android_ripple={{
color: 'rgba(255, 255, 255, 0.3)',
borderless: false,
}}
>
{children}
</Pressable>
);
}
const styles = StyleSheet.create({
button: {
backgroundColor: '#2196F3',
paddingVertical: 12,
paddingHorizontal: 24,
borderRadius: 8,
},
pressedIOS: {
opacity: 0.7,
},
});
Ripple Configuration
// android_ripple options
const rippleConfig = {
// Ripple color with transparency
color: 'rgba(0, 0, 0, 0.2)',
// Ripple contained within bounds vs expanding beyond
borderless: false,
// Custom ripple radius (default: auto-calculated)
radius: 100,
// Ripple starts from touch point (default) or center
foreground: false,
};
// Common ripple patterns
const ripples = {
// For light backgrounds
dark: { color: 'rgba(0, 0, 0, 0.12)' },
// For dark backgrounds
light: { color: 'rgba(255, 255, 255, 0.24)' },
// For icon buttons (circular ripple)
icon: { color: 'rgba(0, 0, 0, 0.12)', borderless: true },
// For colored buttons
onPrimary: { color: 'rgba(255, 255, 255, 0.32)' },
};
Complete Touchable Component
import { Pressable, Platform, StyleSheet, ViewStyle, PressableProps } from 'react-native';
import { ReactNode } from 'react';
interface TouchableProps extends Omit<PressableProps, 'style'> {
children: ReactNode;
style?: ViewStyle;
pressedStyle?: ViewStyle;
rippleColor?: string;
activeOpacity?: number;
}
export function Touchable({
children,
style,
pressedStyle,
rippleColor = 'rgba(0, 0, 0, 0.12)',
activeOpacity = 0.7,
...props
}: TouchableProps) {
return (
<Pressable
{...props}
style={({ pressed }) => [
style,
Platform.OS === 'ios' && pressed && { opacity: activeOpacity },
pressed && pressedStyle,
]}
android_ripple={Platform.OS === 'android' ? { color: rippleColor } : undefined}
>
{children}
</Pressable>
);
}
// Usage examples
function Examples() {
return (
<>
{/* Standard button */}
<Touchable
style={styles.button}
rippleColor="rgba(255,255,255,0.3)"
onPress={() => {}}
>
<Text>Button</Text>
</Touchable>
{/* List item */}
<Touchable
style={styles.listItem}
activeOpacity={0.5}
onPress={() => {}}
>
<Text>List Item</Text>
</Touchable>
</>
);
}
Icon Button with Circular Ripple
function IconButton({ icon, onPress, size = 44 }) {
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [
styles.iconButton,
{ width: size, height: size, borderRadius: size / 2 },
Platform.OS === 'ios' && pressed && styles.iconPressed,
]}
android_ripple={{
color: 'rgba(0, 0, 0, 0.12)',
borderless: true,
radius: size / 2,
}}
>
<Text style={styles.icon}>{icon}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
iconButton: {
justifyContent: 'center',
alignItems: 'center',
},
iconPressed: {
backgroundColor: 'rgba(0, 0, 0, 0.08)',
},
icon: {
fontSize: 24,
},
});
Building Cross-Platform Design Systems
A well-structured design system encapsulates platform differences so your components don't have to think about them.
Theme Structure
// theme/index.ts
import { Platform } from 'react-native';
import { colors } from './colors';
import { spacing } from './spacing';
import { shadows } from './shadows';
import { typography } from './typography';
import { radii } from './radii';
export const theme = {
colors,
spacing,
shadows,
typography,
radii,
// Platform-specific defaults
platform: {
isIOS: Platform.OS === 'ios',
isAndroid: Platform.OS === 'android',
// Common platform conventions
headerHeight: Platform.select({ ios: 44, android: 56 }),
tabBarHeight: Platform.select({ ios: 49, android: 56 }),
hitSlop: Platform.select({ ios: 0, android: 8 }),
},
};
export type Theme = typeof theme;
Complete Theme Files
// theme/colors.ts
export const colors = {
// Semantic colors (same on both platforms)
primary: '#2196F3',
secondary: '#FF9800',
success: '#4CAF50',
error: '#F44336',
warning: '#FFC107',
// Neutral palette
white: '#FFFFFF',
black: '#000000',
gray: {
50: '#FAFAFA',
100: '#F5F5F5',
200: '#EEEEEE',
300: '#E0E0E0',
400: '#BDBDBD',
500: '#9E9E9E',
600: '#757575',
700: '#616161',
800: '#424242',
900: '#212121',
},
// Platform accent colors
accent: Platform.select({
ios: '#007AFF',
android: '#6200EE',
}),
// Text colors
text: {
primary: '#1A1A1A',
secondary: '#666666',
disabled: '#9E9E9E',
inverse: '#FFFFFF',
},
// Background colors
background: {
primary: '#FFFFFF',
secondary: '#F5F5F5',
tertiary: '#EEEEEE',
},
};
// theme/spacing.ts
export const spacing = {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
xxl: 48,
};
// theme/radii.ts
import { Platform } from 'react-native';
export const radii = {
none: 0,
sm: Platform.select({ ios: 6, android: 4 }),
md: Platform.select({ ios: 10, android: 8 }),
lg: Platform.select({ ios: 16, android: 12 }),
xl: Platform.select({ ios: 24, android: 16 }),
full: 9999,
};
Themed Components
// components/Card.tsx
import { View, StyleSheet, ViewProps } from 'react-native';
import { theme } from '../theme';
interface CardProps extends ViewProps {
elevation?: 'sm' | 'md' | 'lg';
}
export function Card({
children,
style,
elevation = 'md',
...props
}: CardProps) {
return (
<View
style={[
styles.card,
theme.shadows[elevation],
style,
]}
{...props}
>
{children}
</View>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: theme.colors.background.primary,
borderRadius: theme.radii.lg,
padding: theme.spacing.md,
},
});
// components/Button.tsx
import { Pressable, Text, StyleSheet, Platform } from 'react-native';
import { theme } from '../theme';
interface ButtonProps {
title: string;
onPress: () => void;
variant?: 'primary' | 'secondary' | 'text';
}
export function Button({ title, onPress, variant = 'primary' }: ButtonProps) {
const isPrimary = variant === 'primary';
const isText = variant === 'text';
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [
styles.button,
isPrimary && styles.primaryButton,
isText && styles.textButton,
Platform.OS === 'ios' && pressed && styles.pressedIOS,
]}
android_ripple={
isPrimary
? { color: 'rgba(255,255,255,0.3)' }
: { color: `${theme.colors.accent}20` }
}
>
<Text style={[
styles.buttonText,
isPrimary && styles.primaryButtonText,
!isPrimary && styles.secondaryButtonText,
]}>
{Platform.OS === 'android' && !isText ? title.toUpperCase() : title}
</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
button: {
paddingVertical: theme.spacing.sm + 4,
paddingHorizontal: theme.spacing.md,
borderRadius: theme.radii.md,
alignItems: 'center',
justifyContent: 'center',
},
primaryButton: {
backgroundColor: theme.colors.accent,
...theme.shadows.sm,
},
textButton: {
backgroundColor: 'transparent',
},
pressedIOS: {
opacity: 0.7,
},
buttonText: {
...theme.typography.button,
},
primaryButtonText: {
color: theme.colors.text.inverse,
},
secondaryButtonText: {
color: theme.colors.accent,
},
});
Using Theme with Context
// context/ThemeContext.tsx
import { createContext, useContext, ReactNode } from 'react';
import { theme, Theme } from '../theme';
const ThemeContext = createContext<Theme>(theme);
export function ThemeProvider({ children }: { children: ReactNode }) {
return (
<ThemeContext.Provider value={theme}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme(): Theme {
return useContext(ThemeContext);
}
// Usage in components
function ThemedCard() {
const theme = useTheme();
return (
<View style={[
styles.card,
{
backgroundColor: theme.colors.background.primary,
borderRadius: theme.radii.lg,
...theme.shadows.md,
}
]}>
<Text style={{ color: theme.colors.text.primary }}>
Themed content
</Text>
</View>
);
}
flowchart TB
subgraph Theme["Design System Theme"]
C["Colors"]
S["Spacing"]
T["Typography"]
SH["Shadows"]
R["Radii"]
end
subgraph Platform["Platform Adaptation"]
PS["Platform.select()"]
end
subgraph Components["Themed Components"]
BTN["Button"]
CARD["Card"]
TXT["Text"]
INP["Input"]
end
Theme --> Platform
Platform --> Components
style Theme fill:#e3f2fd
style Platform fill:#fff3e0
style Components fill:#e8f5e9
Hands-On Exercises
Exercise 1: Cross-Platform Shadow Utility
Create a createShadow() function that accepts shadow parameters and returns the correct styles for both iOS and Android.
Show Solution
import { Platform, ViewStyle } from 'react-native';
interface ShadowParams {
color?: string;
opacity?: number;
offsetX?: number;
offsetY?: number;
radius?: number;
elevation?: number;
}
export function createShadow({
color = '#000000',
opacity = 0.25,
offsetX = 0,
offsetY = 4,
radius = 8,
elevation = 4,
}: ShadowParams = {}): ViewStyle {
if (Platform.OS === 'ios') {
return {
shadowColor: color,
shadowOffset: { width: offsetX, height: offsetY },
shadowOpacity: opacity,
shadowRadius: radius,
};
}
if (Platform.OS === 'android') {
return {
elevation: elevation,
};
}
// Web fallback
return {
boxShadow: `${offsetX}px ${offsetY}px ${radius}px rgba(0,0,0,${opacity})`,
} as ViewStyle;
}
// Usage
const styles = StyleSheet.create({
card: {
backgroundColor: '#fff',
...createShadow({ elevation: 6, radius: 12 }),
},
});
Exercise 2: Platform-Adaptive Button
Create a Button component that uses iOS-style opacity feedback on iOS and Material ripple on Android, with appropriate styling for each platform.
Show Solution
import { Pressable, Text, StyleSheet, Platform, ViewStyle } from 'react-native';
interface AdaptiveButtonProps {
title: string;
onPress: () => void;
variant?: 'filled' | 'outlined' | 'text';
color?: string;
}
export function AdaptiveButton({
title,
onPress,
variant = 'filled',
color = Platform.select({ ios: '#007AFF', android: '#6200EE' }),
}: AdaptiveButtonProps) {
const isFilled = variant === 'filled';
const buttonStyle: ViewStyle = {
...styles.base,
...(isFilled ? { backgroundColor: color } : {}),
...(variant === 'outlined' ? { borderWidth: 1, borderColor: color } : {}),
...Platform.select({
ios: styles.iosButton,
android: styles.androidButton,
}),
};
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [
buttonStyle,
Platform.OS === 'ios' && pressed && { opacity: 0.7 },
]}
android_ripple={{
color: isFilled ? 'rgba(255,255,255,0.3)' : `${color}30`,
}}
>
<Text style={[
styles.text,
Platform.OS === 'android' && styles.androidText,
{ color: isFilled ? '#fff' : color },
]}>
{Platform.OS === 'android' ? title.toUpperCase() : title}
</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
base: {
paddingHorizontal: 20,
alignItems: 'center',
justifyContent: 'center',
},
iosButton: {
paddingVertical: 12,
borderRadius: 10,
},
androidButton: {
paddingVertical: 10,
borderRadius: 4,
elevation: 2,
},
text: {
fontWeight: '600',
},
androidText: {
fontSize: 14,
letterSpacing: 1,
fontWeight: '500',
},
});
Exercise 3: Platform Typography System
Create a typography system with variants (h1, h2, body, caption) that uses appropriate font weights and sizes for each platform.
Show Solution
import { Platform, TextStyle } from 'react-native';
type FontWeight = TextStyle['fontWeight'];
// Platform-appropriate weights
const weights = {
regular: Platform.select<FontWeight>({ ios: '400', android: '400' }),
medium: Platform.select<FontWeight>({ ios: '500', android: '500' }),
semibold: Platform.select<FontWeight>({ ios: '600', android: 'bold' }),
bold: Platform.select<FontWeight>({ ios: '700', android: 'bold' }),
};
export const typography = {
h1: {
fontSize: Platform.select({ ios: 34, android: 32 }),
fontWeight: weights.bold,
lineHeight: Platform.select({ ios: 41, android: 40 }),
letterSpacing: Platform.select({ ios: 0.37, android: 0 }),
} as TextStyle,
h2: {
fontSize: Platform.select({ ios: 28, android: 24 }),
fontWeight: weights.semibold,
lineHeight: Platform.select({ ios: 34, android: 32 }),
} as TextStyle,
h3: {
fontSize: Platform.select({ ios: 22, android: 20 }),
fontWeight: weights.semibold,
lineHeight: Platform.select({ ios: 28, android: 28 }),
} as TextStyle,
body: {
fontSize: Platform.select({ ios: 17, android: 16 }),
fontWeight: weights.regular,
lineHeight: Platform.select({ ios: 22, android: 24 }),
} as TextStyle,
bodySmall: {
fontSize: Platform.select({ ios: 15, android: 14 }),
fontWeight: weights.regular,
lineHeight: Platform.select({ ios: 20, android: 20 }),
} as TextStyle,
caption: {
fontSize: Platform.select({ ios: 12, android: 12 }),
fontWeight: weights.regular,
lineHeight: Platform.select({ ios: 16, android: 16 }),
letterSpacing: 0.4,
} as TextStyle,
button: {
fontSize: Platform.select({ ios: 17, android: 14 }),
fontWeight: weights.semibold,
letterSpacing: Platform.select({ ios: 0, android: 1.25 }),
} as TextStyle,
};
// Usage
const styles = StyleSheet.create({
title: {
...typography.h1,
color: '#1a1a1a',
},
paragraph: {
...typography.body,
color: '#666',
},
});
Summary
You now have the tools to create apps that feel native on both iOS and Android while sharing the majority of your code.
🎯 Key Takeaways
- Platform.OS detects the current platform ('ios' | 'android')
- Platform.select() is the cleanest way to define platform-specific values
- Platform-specific files (.ios.tsx, .android.tsx) for significantly different implementations
- Shadows require different properties: iOS uses 4 shadow properties, Android uses elevation
- Touch feedback differs: iOS uses opacity, Android uses ripples
- Design systems should encapsulate platform differences in reusable utilities
Quick Reference
import { Platform, StyleSheet } from 'react-native';
// Detect platform
const isIOS = Platform.OS === 'ios';
const isAndroid = Platform.OS === 'android';
// Platform-specific values
const value = Platform.select({
ios: 'iOS value',
android: 'Android value',
default: 'fallback',
});
// Cross-platform shadows
const shadow = Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
},
android: {
elevation: 4,
},
});
// Touch feedback
<Pressable
style={({ pressed }) => [
styles.button,
Platform.OS === 'ios' && pressed && { opacity: 0.7 },
]}
android_ripple={{ color: 'rgba(0,0,0,0.12)' }}
/>
Coming Up Next
In the next lesson, we'll explore Animations Basics. You'll learn how to bring your UI to life with React Native's Animated API and create smooth, performant animations.