Module 4: StyleSheet Deep Dive

Platform-Specific Styles

Write once, look native everywhere

Table of Contents

🎯 Learning Objectives

  • Understand the key visual differences between iOS and Android
  • Use the Platform module 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
iOS Style Card Title Description text here Learn More Soft shadow, SF font, blue links Android Style Card Title Description text here LEARN MORE Sharp shadow, Roboto, Material buttons Shared Between Platforms Layout structure, colors, spacing, component logic

💡 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,
  },
});
sm elevation: 1 md elevation: 3 lg elevation: 6 xl elevation: 12 Shadow presets scale from subtle (sm) to dramatic (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 colors
  • backgroundColor: Sets bar color
  • translucent: 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,
  },
});
iOS Touch Normal Pressed Opacity: 1.0 → 0.7 Android Touch Normal Ripple Expanding ripple effect

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.