Overview
React Native UI differs fundamentally from web UI. There is no CSS, no DOM, no browser layout engine. Everything renders to native platform views through a bridge. Understanding this architecture prevents the most common mistakes.
Core principle: Think in native components and Flexbox, not in HTML/CSS. Every visual element maps to a platform-native view.
When to Use
- Building screens, components, or layouts in React Native
- Implementing navigation between screens
- Adding animations or gesture interactions
- Using native platform controls (switches, sliders, segmented controls)
- Styling components (StyleSheet, not CSS)
- Creating modals, form sheets, or bottom sheets
Navigation with Expo Router
Expo Router uses file-based routing, similar to Next.js. Files in the app/ directory automatically become routes.
Directory Structure
app/
_layout.tsx # Root layout (wraps all routes)
index.tsx # Home screen (/)
settings.tsx # /settings
(tabs)/
_layout.tsx # Tab navigator layout
home.tsx # Tab: home
profile.tsx # Tab: profile
[id].tsx # Dynamic route: /123, /abc
(auth)/
login.tsx # Group: doesn't affect URL
Layout Files
// app/_layout.tsx
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<Stack>
<Stack.Screen name="index" options={{ title: 'Home' }} />
<Stack.Screen name="settings" options={{ headerShown: false }} />
</Stack>
);
}
Navigation
import { Link, useRouter } from 'expo-router';
// Declarative
<Link href="/settings">Go to Settings</Link>
<Link href={{ pathname: '/user/[id]', params: { id: '42' } }}>User 42</Link>
// Imperative
const router = useRouter();
router.push('/settings');
router.replace('/login'); // No back button
router.back();
Tab Navigation
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
export default function TabLayout() {
return (
<Tabs>
<Tabs.Screen
name="home"
options={{
title: 'Home',
tabBarIcon: ({ color, size }) => (
<Ionicons name="home" size={size} color={color} />
),
}}
/>
</Tabs>
);
}
Flexbox Layouts
React Native uses Flexbox by default. Key differences from web Flexbox:
| Property | React Native Default | Web Default |
|---|---|---|
flexDirection |
column |
row |
alignContent |
flex-start |
stretch |
| Units | Unitless (dp) | px, em, rem, etc. |
Common Layout Patterns
// Centering content
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
});
// Row with spacing
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
},
});
// Scrollable list with fixed header/footer
const styles = StyleSheet.create({
container: { flex: 1 },
header: { height: 60 },
content: { flex: 1 }, // Takes remaining space
footer: { height: 80 },
});
Safe Areas
Always account for notches, status bars, and home indicators:
import { SafeAreaView } from 'react-native-safe-area-context';
export default function Screen() {
return (
<SafeAreaView style={{ flex: 1 }} edges={['top', 'bottom']}>
{/* Content */}
</SafeAreaView>
);
}
React Native Reanimated
For performant, 60fps animations that run on the native thread.
Basic Animation
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
} from 'react-native-reanimated';
function AnimatedBox() {
const offset = useSharedValue(0);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: offset.value }],
}));
return (
<Animated.View style={[styles.box, animatedStyle]}>
<Pressable onPress={() => {
offset.value = withSpring(offset.value === 0 ? 200 : 0);
}}>
<Text>Tap me</Text>
</Pressable>
</Animated.View>
);
}
Entering/Exiting Animations
import Animated, { FadeIn, SlideOutRight } from 'react-native-reanimated';
<Animated.View entering={FadeIn.duration(500)} exiting={SlideOutRight}>
<Text>I animate in and out</Text>
</Animated.View>
Gesture Handler + Reanimated
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
useSharedValue,
useAnimatedStyle,
withDecay,
} from 'react-native-reanimated';
function DraggableCard() {
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const pan = Gesture.Pan()
.onUpdate((event) => {
translateX.value = event.translationX;
translateY.value = event.translationY;
})
.onEnd((event) => {
translateX.value = withDecay({ velocity: event.velocityX });
translateY.value = withDecay({ velocity: event.velocityY });
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value },
],
}));
return (
<GestureDetector gesture={pan}>
<Animated.View style={[styles.card, animatedStyle]} />
</GestureDetector>
);
}
Native iOS Controls via @expo/ui
Use platform-native controls that match iOS design language:
import { Switch, Slider, SegmentedControl } from '@expo/ui';
function SettingsScreen() {
const [enabled, setEnabled] = useState(false);
const [volume, setVolume] = useState(0.5);
const [tab, setTab] = useState(0);
return (
<View>
<Switch value={enabled} onValueChange={setEnabled} />
<Slider
value={volume}
onValueChange={setVolume}
minimumValue={0}
maximumValue={1}
/>
<SegmentedControl
values={['Daily', 'Weekly', 'Monthly']}
selectedIndex={tab}
onValueChange={setTab}
/>
</View>
);
}
SF Symbols Integration
Use Apple's SF Symbols icon set on iOS:
import { SymbolView } from 'expo-symbols';
<SymbolView
name="heart.fill"
style={{ width: 24, height: 24 }}
tintColor="red"
weight="semibold"
/>
Common symbol names: chevron.right, gear, person.fill, star.fill, bell.fill, magnifyingglass, plus.circle.fill.
Modals and Form Sheets
Stack Modal
// app/_layout.tsx
<Stack>
<Stack.Screen name="index" />
<Stack.Screen
name="modal"
options={{
presentation: 'modal',
headerTitle: 'Settings',
}}
/>
</Stack>
// app/modal.tsx - presented as native modal
Form Sheet (iOS 16+)
<Stack.Screen
name="form-sheet"
options={{
presentation: 'formSheet',
sheetAllowedDetents: [0.5, 1.0], // Half and full screen
sheetGrabberVisible: true,
sheetCornerRadius: 24,
}}
/>
Host Component Wrapping
When you need to wrap native views for use with platform-specific APIs:
import { requireNativeView } from 'expo';
const NativeMapView = requireNativeView('MapView');
function Map({ region, onRegionChange }) {
return (
<NativeMapView
style={{ flex: 1 }}
region={region}
onRegionChange={onRegionChange}
/>
);
}
Styling in React Native
StyleSheet API
import { StyleSheet } from 'react-native';
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
padding: 16,
},
title: {
fontSize: 24,
fontWeight: 'bold',
color: '#333',
marginBottom: 8,
},
// Shadow (iOS)
card: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
// Shadow (Android)
elevation: 3,
borderRadius: 8,
backgroundColor: '#fff',
padding: 16,
},
});
Key Differences from CSS
- No cascading: styles don't inherit (except
TextwithinText) - No units: all numbers are density-independent pixels
- No shorthand:
margin: 16works, butmargin: '16px 8px'does not - camelCase:
backgroundColornotbackground-color - No pseudo-classes: use
Pressablewith style functions for hover/press states - Platform-specific shadows:
shadowXfor iOS,elevationfor Android
Pressable Styling
<Pressable
style={({ pressed }) => [
styles.button,
pressed && styles.buttonPressed,
]}
>
{({ pressed }) => (
<Text style={pressed ? styles.textPressed : styles.text}>
Press me
</Text>
)}
</Pressable>
Platform-Specific Styles
import { Platform, StyleSheet } from 'react-native';
const styles = StyleSheet.create({
container: {
...Platform.select({
ios: { shadowColor: '#000', shadowOpacity: 0.1 },
android: { elevation: 3 },
}),
},
});
Common Mistakes
| Mistake | Fix |
|---|---|
Using 100vh or CSS units |
Use flex: 1 or Dimensions.get('window').height |
| Forgetting SafeAreaView | Always wrap top-level screens in SafeAreaView |
Animating with setState |
Use Reanimated shared values for 60fps animations |
Nesting ScrollView in ScrollView |
Use FlatList with ListHeaderComponent |
Using overflow: 'hidden' expecting CSS behavior |
Android clips children differently; test both platforms |
| Hardcoding dimensions | Use flex, %, or useWindowDimensions() |
Missing key prop in lists |
Use FlatList with keyExtractor instead of .map() |
Quick Reference
| Task | Solution |
|---|---|
| Navigate to screen | router.push('/path') or <Link href="/path"> |
| Tab navigation | (tabs)/_layout.tsx with <Tabs> |
| Center content | justifyContent: 'center', alignItems: 'center' |
| Spring animation | offset.value = withSpring(target) |
| Native switch | <Switch> from @expo/ui |
| Modal screen | presentation: 'modal' in Stack options |
| Platform check | Platform.OS === 'ios' |
| Safe area | <SafeAreaView edges={['top']}> |