콘텐츠로 이동

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-palette skill 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:

fontFamily: 'Pretendard-Regular',  // exact match

For variable fonts (Pretendard Variable), use:

fontFamily: 'Pretendard-Variable',
fontWeight: '500',  // axis instance

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.

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: 1 adds visual height that flex doesn't account for. Plan layouts assuming this.
  • Text doesn't word-wrap correctly with absolute width: use flex: 1 on parent, flexShrink: 1 on text, or numberOfLines with ellipsizeMode.
  • Image performance: large images crash on low-end Android. Use react-native-fast-image or 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 like react-native-modal or react-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), set allowFontScaling={false} — but use sparingly.
  • iOS focus ring on text inputs: there isn't one by default. Build it via onFocus/onBlur state + border color change.
  • Android elevation doesn't show shadow on transparent backgrounds: set a backgroundColor even 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