title: React Native — platform-specific design notes applies_to: [react-native, mobile, ios, android, expo] version: 1.0.0 last_updated: 2026-05 stability: stable
React Native platform notes¶
The web ↔ React Native gap is wider than it looks. Specs and tokens that work on web don't always translate. This is the bridge.
Use this knowledge when¶
- Component spec or skill is being applied to a React Native project.
- Design tokens generated by
color-paletteskill need RN consumption. - Designer is hand-off-ing to an RN engineer.
- Spec mentions
:hover,cursor,box-shadow— those need translation.
Core differences¶
| Concept | Web | React Native |
|---|---|---|
| Layout default | block (flex none) |
flex (column direction) |
| Box model | content + padding + border + margin | flex-based; no margin collapse |
| Cursor | yes (cursor: pointer) |
no — feedback via Pressable press states |
| Hover | yes (:hover) |
no — no hover on touch. Use press states + long-press where applicable |
| Focus | :focus, :focus-visible |
partial — Pressable has focused state for keyboard nav (mostly desktop tvOS) |
| Scroll | native to most elements | only ScrollView, FlatList, SectionList scroll |
| Z-index | works everywhere | works on iOS, partial on Android (use elevation) |
| Shadows | box-shadow |
shadowColor, shadowOffset, shadowOpacity, shadowRadius (iOS) + elevation (Android) |
| Borders | per-side | per-side, but border-radius corner-specific is iOS-only on Android <12 |
| Animations | CSS transitions, @keyframes |
Animated, Reanimated, or LayoutAnimation |
| Media queries | @media (min-width: ...) |
Dimensions.get('window') + Platform.OS, or useWindowDimensions |
| Text | inherits styles | does not inherit — every <Text> starts fresh |
| Default text in non-Text | renders | crashes (<View>some string</View> errors) |
Tokens — translation¶
Code-side (CSS variables → RN constants):
/* Web (CSS) */
:root {
--color-primary-default: #0D9488;
--color-bg-default: #FFFFFF;
--space-md: 12px;
--radius-md: 10px;
--shadow-card: 0 1px 2px rgba(15, 23, 42, 0.04);
}
// RN (constants)
export const tokens = {
color: {
primary: { default: '#0D9488', hover: '#0F766E', active: '#115E59' },
bg: { default: '#FFFFFF', elevated: '#F8FAFC' },
},
space: { xs: 4, sm: 8, md: 12, base: 16, lg: 20 }, // numbers, NOT '12px'
radius: { sm: 6, md: 10, lg: 16, full: 9999 },
shadow: {
card: {
// iOS:
shadowColor: '#0F172A',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.04,
shadowRadius: 2,
// Android:
elevation: 1,
},
},
};
Notes:
- Numbers, not strings: RN expects raw numbers for padding, margin, borderRadius. 12 not '12px'.
- Shadow is two systems: iOS uses 4 props; Android uses elevation (a single number 0–24 mapping to Material elevation). Provide both in every shadow token.
- Color references: RN doesn't support CSS variable references at runtime (without NativeWind/Tailwind). Use a JS theme object accessed via useTheme().
NativeWind / Tailwind RN¶
If using NativeWind, keep tokens as CSS-flavored variables in a tailwind.config.js. NativeWind translates them to platform-specific styles at compile.
// tailwind.config.js (NativeWind compatible)
module.exports = {
theme: {
extend: {
colors: {
'primary-default': '#0D9488',
'bg-default': '#FFFFFF',
},
spacing: {
'md': '12px', // NativeWind handles px → number
},
},
},
};
NativeWind has gaps (no shadow utility on Android, no certain pseudo-classes). Document these in your project's STYLES.md.
Pressable — the universal interactive primitive¶
There is no <Button> component in core RN. Use Pressable and design your own Button.
<Pressable
style={({ pressed, hovered, focused }) => [
styles.button,
pressed && styles.buttonPressed,
hovered && styles.buttonHovered, // tvOS / desktop only
focused && styles.buttonFocused, // keyboard / tvOS
]}
onPress={handlePress}
android_ripple={{ color: rippleColor }} // Android-native ripple
accessibilityRole="button"
accessibilityLabel="Save changes"
>
<Text style={styles.buttonText}>Save</Text>
</Pressable>
Press feedback options:
- iOS-style: scale to 0.97 on press, fade to 0.7 opacity. Smooth.
- Android-style: ripple (use android_ripple prop). Native to platform.
- Cross-platform compromise: opacity 0.7 on press (works both, doesn't feel native to either).
Best practice: use platform-specific feedback.
import { Platform } from 'react-native';
const pressStyle = Platform.select({
ios: { transform: [{ scale: 0.97 }], opacity: 0.85 },
android: {}, // ripple handles it
});
Touch targets¶
| Standard | Minimum |
|---|---|
| iOS HIG | 44×44 pt |
| Android Material | 48×48 dp |
| Practical default | 44 (iOS) / 48 (Android) |
In RN, point/dp coordinates map cleanly. A Pressable with style={{ width: 44, height: 44 }} is correct on both.
If the visual element is smaller (e.g., a 24x24 icon button), extend the hit area without growing the visual:
<Pressable
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
style={{ width: 24, height: 24 }}
>
<Icon />
</Pressable>
hitSlop extends the touch zone outside the visual. Native and free.
Safe area¶
iOS notch + home indicator, Android system bars. Always wrap your screen content in SafeAreaView or use useSafeAreaInsets:
import { SafeAreaView } from 'react-native-safe-area-context';
<SafeAreaView style={{ flex: 1, backgroundColor: tokens.color.bg.default }}>
<ScreenContent />
</SafeAreaView>
For full-bleed designs (image hero), don't wrap in SafeAreaView; use useSafeAreaInsets() to read inset values and apply only where needed (e.g., paddingTop: insets.top on the back button).
const insets = useSafeAreaInsets();
<View style={{ paddingTop: insets.top, paddingBottom: insets.bottom }}>...</View>
Keyboard handling¶
Software keyboard appears → covers form. iOS uses KeyboardAvoidingView with behavior="padding"; Android uses windowSoftInputMode="adjustResize" in AndroidManifest.xml. Cross-platform pattern:
import { KeyboardAvoidingView, Platform } from 'react-native';
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={{ flex: 1 }}
>
<ScrollView>
<Form />
</ScrollView>
</KeyboardAvoidingView>
For sticky footer buttons (e.g., "Save" at bottom): use keyboardVerticalOffset to push the footer above the keyboard, not the whole screen.
Animations¶
Three options:
| Tool | Best for | Notes |
|---|---|---|
Animated API (built-in) |
Simple animations | Imperative, verbose |
react-native-reanimated |
Production-grade, runs on UI thread | Default for new projects. Best perf. |
LayoutAnimation |
Layout transitions (e.g., list reorder) | Easy but limited control |
Reanimated v3 example:
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
function Card() {
const scale = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
return (
<Pressable
onPressIn={() => (scale.value = withSpring(0.97))}
onPressOut={() => (scale.value = withSpring(1))}
>
<Animated.View style={[styles.card, animatedStyle]}>...</Animated.View>
</Pressable>
);
}
Reduced motion: AccessibilityInfo.isReduceMotionEnabled() — gate animations.
Typography¶
// All text MUST be in <Text>
<View>
<Text style={styles.heading}>Title</Text>
<Text style={styles.body}>Body content</Text>
</View>
// This CRASHES:
<View>Plain text</View> // ✗
Font loading (Pretendard for Korean):
import * as Font from 'expo-font';
import { useEffect, useState } from 'react';
await Font.loadAsync({
'Pretendard-Regular': require('./assets/fonts/Pretendard-Regular.otf'),
'Pretendard-Medium': require('./assets/fonts/Pretendard-Medium.otf'),
'Pretendard-SemiBold': require('./assets/fonts/Pretendard-SemiBold.otf'),
'Pretendard-Bold': require('./assets/fonts/Pretendard-Bold.otf'),
});
Reference fonts in styles by string name matching the loaded family:
For variable fonts (Pretendard Variable), use:
Variable font support varies — test on both platforms before committing.
iOS-only: SF Pro¶
iOS system font is San Francisco. To use it explicitly: fontFamily: undefined or omit fontFamily. iOS picks SF Pro by weight/style.
For Korean primary apps targeting iOS users: prefer Pretendard over SF Pro (Pretendard handles Hangul + Latin matched). For Western-coded apps: use system font.
Lists — FlatList over ScrollView¶
For long lists (> 20 items), never use ScrollView. Use FlatList:
<FlatList
data={transactions}
renderItem={({ item }) => <TransactionRow item={item} />}
keyExtractor={(item) => item.id}
ItemSeparatorComponent={() => <View style={styles.separator} />}
contentContainerStyle={{ padding: tokens.space.md }}
// Performance:
windowSize={11}
maxToRenderPerBatch={10}
removeClippedSubviews
// Pull to refresh:
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
/>
For sectioned lists: SectionList.
Navigation¶
There is no built-in navigation in RN. The de-facto standard is @react-navigation/native with stack/tab/drawer navigators.
Token-aware setup:
import { ThemeProvider } from '@react-navigation/native';
const navigationTheme = {
dark: false,
colors: {
primary: tokens.color.primary.default,
background: tokens.color.bg.default,
card: tokens.color.bg.elevated,
text: tokens.color.text.primary,
border: tokens.color.border.default,
notification: tokens.color.error,
},
};
<NavigationContainer theme={navigationTheme}>
<Stack.Navigator>...</Stack.Navigator>
</NavigationContainer>
For mobile patterns (bottom-tab-bar, etc.), see knowledge/patterns/mobile-navigation.md.
Platform-specific code¶
import { Platform } from 'react-native';
// Branching
if (Platform.OS === 'ios') { ... }
if (Platform.OS === 'android') { ... }
// Selecting values
const padding = Platform.select({
ios: 16,
android: 12,
default: 16,
});
// Version-specific
if (Platform.OS === 'ios' && parseFloat(Platform.Version) >= 17) { ... }
For files that diverge significantly: name files MyComponent.ios.tsx and MyComponent.android.tsx — Metro bundler picks per platform automatically.
Common pitfalls (designers should know)¶
- Borders inside flex children render with weird spacing: setting
borderWidth: 1adds visual height that flex doesn't account for. Plan layouts assuming this. - Text doesn't word-wrap correctly with absolute width: use
flex: 1on parent,flexShrink: 1on text, ornumberOfLineswithellipsizeMode. - Image performance: large images crash on low-end Android. Use
react-native-fast-imageor properly sized assets (don't ship 4K thumbnails). - Modal vs Dialog: RN's built-in
<Modal>covers full screen by default. For card-style dialogs, use a library likereact-native-modalorreact-native-bottom-sheet. - Font scaling (accessibility): iOS/Android both let users scale system text. By default, RN respects this (
<Text>scales). For fixed-size UI text (like a logo), setallowFontScaling={false}— but use sparingly. - iOS focus ring on text inputs: there isn't one by default. Build it via
onFocus/onBlurstate + border color change. - Android
elevationdoesn't show shadow on transparent backgrounds: set abackgroundColoreven if it matches the parent. - Status bar: customize via
<StatusBar>from RN — light or dark content based on the screen's background.
Tokens that don't translate cleanly¶
| Token type | Web | RN status |
|---|---|---|
| Color | trivial | trivial |
| Spacing | trivial (px → number) | trivial |
| Radius | trivial | trivial; per-corner partial on Android <12 |
| Border | mostly | mostly; dashed/dotted spotty on Android |
| Shadow | one prop | 5 props (4 iOS + elevation) |
| Motion duration | transition: 200ms |
requires Animated/Reanimated config |
| Motion easing | cubic-bezier(...) |
use Easing.bezier(0, 0, 0.2, 1) from react-native |
| Z-index | works | iOS-only above Android <11 |
| Transition | CSS | requires animation library |
| Hover | :hover |
no equivalent — design without it |
| Cursor | cursor: pointer |
no equivalent — feedback via Pressable |
When porting a web design system to RN: budget time for re-authoring shadows and motion specifically.
Testing¶
- Detox for E2E (industry standard for RN).
- Maestro for simpler, declarative E2E (newer, easier setup).
- React Native Testing Library for component tests.
Cross-reference¶
- knowledge/i18n/korean-typography.md — Pretendard loading on RN
- knowledge/patterns/mobile-navigation.md — bottom-tab-bar, top-app-bar
- knowledge/motion/principles.md — durations and easings (translate to Reanimated)
- knowledge/a11y/keyboard-and-focus.md — touch targets, focus management