Expo App Design
When to Use
- Building mobile or universal apps with Expo and Expo Router
- Implementing native navigation: tabs, stacks, modals, form sheets
- Adding animations, visual effects, or platform-specific behaviors to React Native apps
- Working with native iOS/Android UI conventions (SF Symbols, haptics, blur, glass)
When NOT to Use
- Tailwind styling setup — use
expo-tailwindskill - Framer Motion or web animations — use
animationskill (web-only) - React Native performance optimization (FlatList, Hermes, JS thread) — use
react-nativeskill
Expo Go vs Custom Builds
Always try Expo Go first (npx expo start). It supports all expo-* packages, Expo Router, Reanimated, Gesture Handler, and push notifications out of the box.
Only use npx expo run:ios / npx expo run:android when you need:
- Local Expo modules (
modules/with custom native code) - Apple targets (widgets, app clips via
@bacons/apple-targets) - Third-party native modules not bundled in Expo Go
Expo Router: File-Based Routing
app/
_layout.tsx — Root layout (NativeTabs, Theme provider)
(index,search)/
_layout.tsx — Stack shared by both tabs
index.tsx
search.tsx
i/[id].tsx — Dynamic route
- Never co-locate components, types, or utilities in
app/ - Always define stacks in
_layout.tsx— never inline in screens - App must always have a route matching
/ - Use kebab-case filenames; remove old route files when restructuring
Native Tabs with SF Symbols
// app/_layout.tsx
import { NativeTabs, Icon, Label } from "expo-router/unstable-native-tabs";
export default function Layout() {
return (
<NativeTabs>
<NativeTabs.Trigger name="(index)">
<Icon sf="list.dash" />
<Label>Items</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(search)" role="search" />
</NativeTabs>
);
}
// app/(index,search)/_layout.tsx
import { Stack } from "expo-router/stack";
import { PlatformColor } from "react-native";
export default function Layout({ segment }: { segment: string }) {
const screen = segment.match(/\((.*)\)/)?.[1]!;
const titles: Record<string, string> = { index: "Items", search: "Search" };
return (
<Stack
screenOptions={{
headerTransparent: true,
headerLargeTitle: true,
headerLargeStyle: { backgroundColor: "transparent" },
headerTitleStyle: { color: PlatformColor("label") },
headerShadowVisible: false,
headerBackButtonDisplayMode: "minimal",
}}
>
<Stack.Screen name={screen} options={{ title: titles[screen] }} />
<Stack.Screen name="i/[id]" options={{ headerLargeTitle: false }} />
</Stack>
);
}
Form Sheets / Modals
Navigate to sheets with router.push(). Configure in _layout.tsx:
<Stack.Screen
name="sheet"
options={{
presentation: "formSheet",
sheetGrabberVisible: true,
sheetAllowedDetents: [0.5, 1.0],
contentStyle: { backgroundColor: "transparent" }, // liquid glass on iOS 26+
}}
/>
For a basic modal: presentation: "modal". Always prefer route-based sheets over custom modal components.
Reanimated
import Animated, {
useSharedValue, useAnimatedStyle, withSpring,
useAnimatedScrollHandler, interpolate,
} from "react-native-reanimated";
// Press scale
const scale = useSharedValue(1);
const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
// onPressIn: scale.value = withSpring(0.96)
// onPressOut: scale.value = withSpring(1)
// Scroll-driven header
const scrollY = useSharedValue(0);
const => { scrollY.value = e.contentOffset.y; });
const headerStyle = useAnimatedStyle(() => ({
opacity: interpolate(scrollY.value, [0, 80], [1, 0]),
}));
Add entering/exiting animations for state changes. Use withSpring for natural feel.
expo-image (Preferred Over <Image>)
import { Image } from "expo-image";
// Remote with blur placeholder
<Image
source={{ uri: "https://example.com/photo.jpg" }}
placeholder={{ blurhash: "L6PZfSi_.AyE_3t7t7R**0o#DgR4" }}
contentFit="cover"
style={{ width: 200, height: 200, borderRadius: 12 }}
/>
// SF Symbol (iOS native icon)
<Image source="sf:star.fill" style={{ width: 24, height: 24, tintColor: "#FFD700" }} />
Never use the RN <Image> component or <img> element. Use contentFit not resizeMode.
Storage: When to Use Each
| Data type | Package |
|---|---|
| Structured relational data | expo-sqlite |
| Simple key-value (non-sensitive) | @react-native-async-storage/async-storage |
| Sensitive data (tokens, secrets) | expo-secure-store |
Never use AsyncStorage from react-native core (removed) or expo-permissions (legacy).
Visual Effects
import { BlurView } from "expo-blur";
// Frosted overlay
<BlurView intensity={60} tint="systemMaterial" style={StyleSheet.absoluteFill} />
import { GlassView } from "expo-glass-effect";
// Liquid glass (iOS 26+) — pair with transparent sheet contentStyle
<GlassView style={{ flex: 1, borderRadius: 20 }}>
<SheetContent />
</GlassView>
Platform-Specific Patterns
// Tree-shakeable platform detection
if (process.env.EXPO_OS === "ios") { /* iOS only */ }
// Semantic colors that adapt to dark mode
import { PlatformColor } from "react-native";
const color = PlatformColor("label");
// Haptics — guard to iOS only
import * as Haptics from "expo-haptics";
if (process.env.EXPO_OS === "ios") {
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
Prefer views with built-in haptics (<Switch />, @react-native-community/datetimepicker).
Context Menus and Link Previews
Add Link.Preview and Link.Menu to navigable cards to follow iOS conventions:
import { Link } from "expo-router";
<Link href="/item/123" asChild>
<Link.Trigger><Pressable><Card /></Pressable></Link.Trigger>
<Link.Preview />
<Link.Menu>
<Link.MenuAction title="Share" icon="square.and.arrow.up" />
<Link.MenuAction title="Delete" icon="trash" destructive />
</Link.Menu>
</Link>
Layout, Responsiveness, and Styling
- Use
<ScrollView contentInsetAdjustmentBehavior="automatic" />— not<SafeAreaView> - Apply
contentInsetAdjustmentBehavior="automatic"toFlatListandSectionListtoo useWindowDimensions()overDimensions.get()— reacts to orientation changescontentContainerStylefor padding on ScrollView (prevents clipping)- Shadows:
boxShadowCSS prop — never legacyshadow*orelevation { borderCurve: "continuous" }for rounded corners (Apple squircle){ fontVariant: ["tabular-nums"] }for numeric/counter text- Always set
titleinStack.Screen options— never a custom title element on the page - Inline styles over
StyleSheet.create; CSS/Tailwind not supported
Hard Rules
- Never use
divorimg— useViewandexpo-image - Never use removed RN modules:
Picker,WebView,SafeAreaView(RN core),AsyncStorage(RN core) - Never use
expo-av— useexpo-audio+expo-videoseparately - Never use
expo-symbolsor@expo/vector-icons— useexpo-imagewithsf:source - Never use
Platform.OS— useprocess.env.EXPO_OS - Never co-locate components/utilities in
app/
Verification Checklist
- Expo Go used for development; custom build only when native modules required
- Routes in
app/; no components co-located there -
_layout.tsxfiles define all stacks and tab layouts -
NativeTabswithIcon sf=for SF Symbols (not@expo/vector-icons) -
expo-imageused for all images and SF Symbols -
contentInsetAdjustmentBehavior="automatic"on all ScrollView / FlatList / SectionList -
process.env.EXPO_OSused instead ofPlatform.OS - Storage: correct package chosen (SQLite / AsyncStorage / SecureStore)
- No deprecated modules (Picker, WebView, SafeAreaView from RN core)
- Haptics guarded with
process.env.EXPO_OS === "ios" - Shadows use
boxShadow, not legacyshadow*/elevation - Form sheets use
presentation: "formSheet"route config -
Link.PreviewandLink.Menuadded to navigable cards