expo-app-design
Official Expo UI guidelines - build beautiful apps with Expo Router, NativeTabs, SF Symbols, animations, and Apple HIG patterns
Installation
Follow the repository instructions for this skill and your agent. Review the files and dependencies before adding it to your workflow.
View source ↗GuidesSkill instructions
Expo App Design
Complete guide for building beautiful apps with Expo Router. Based on official Expo skills from expo/skills repository.
Running the App
CRITICAL: Always try Expo Go first before creating custom builds.
Most Expo apps work in Expo Go without any custom native code. Before running npx expo run:ios or npx expo run:android:
-
Start with Expo Go: Run
npx expo startand scan the QR code with Expo Go - Check if features work: Test your app thoroughly in Expo Go
- Only create custom builds when required
When Custom Builds Are Required
You need npx expo run:ios/android or eas build ONLY when using:
-
Local Expo modules (custom native code in
modules/) -
Apple targets (widgets, app clips, extensions via
@bacons/apple-targets) - Third-party native modules not included in Expo Go
-
Custom native configuration that can't be expressed in
app.json
Code Style
- Always use kebab-case for file names, e.g.
comment-card.tsx - Always remove old route files when moving or restructuring navigation
- Never use special characters in file names
- Configure tsconfig.json with path aliases, prefer aliases over relative imports
- Be cautious of unterminated strings and escape nested backticks properly
Routes
- Routes belong in the
appdirectory - Never co-locate components, types, or utilities in the app directory
- Ensure the app always has a route that matches "/"
Library Preferences
| Old/Avoid | Use Instead |
|---|---|
expo-av |
expo-audio and expo-video |
expo-permissions |
Individual package permission APIs |
@expo/vector-icons |
expo-symbols (SF Symbols) |
AsyncStorage |
expo-sqlite/localStorage/install |
expo-app-loading |
expo-splash-screen |
Platform.OS |
process.env.EXPO_OS |
React.useContext |
React.use |
Intrinsic img |
expo-image Image component |
expo-linear-gradient |
experimental_backgroundImage + CSS gradients |
React Native SafeAreaView |
react-native-safe-area-context |
Responsiveness
- Always wrap root component in a scroll view for responsiveness
- Use
<ScrollView contentInsetAdjustmentBehavior="automatic" />instead of<SafeAreaView> - Apply
contentInsetAdjustmentBehavior="automatic"to FlatList and SectionList - Use flexbox instead of Dimensions API
- ALWAYS prefer
useWindowDimensionsoverDimensions.get()
Styling (Apple HIG)
- Prefer flex gap over margin and padding styles
- Prefer padding over margin where possible
- Inline styles not StyleSheet.create unless reusing styles
- Add entering and exiting animations for state changes
- Use
{ borderCurve: 'continuous' }for rounded corners unless creating a capsule - ALWAYS use a navigation stack title instead of custom text element
- Use CSS
boxShadowstyle prop, NEVER use legacy React Native shadow or elevation - CSS and Tailwind are not supported - use inline styles
// Correct shadow usage
<View style={{ boxShadow: "0 1px 2px rgba(0, 0, 0, 0.05)" }} />
Text Styling
- Add
selectableprop to<Text/>displaying important data or error messages - Counters should use
{ fontVariant: 'tabular-nums' }for alignment
Navigation
Link
Use <Link href="/path" /> from 'expo-router' for navigation:
import { Link } from 'expo-router';
// Basic link
<Link href="/path" />
// Wrapping custom components
<Link href="/path" asChild>
<Pressable>...</Pressable>
</Link>
Include <Link.Preview> to follow iOS conventions for context menus and previews:
<Link href="/settings">
<Link.Trigger>
<Pressable><Card /></Pressable>
</Link.Trigger>
<Link.Preview />
</Link>
Context Menus
<Link href="/settings" asChild>
<Link.Trigger>
<Pressable><Card /></Pressable>
</Link.Trigger>
<Link.Menu>
<Link.MenuAction title="Share" icon="square.and.arrow.up" onPress={handleShare} />
<Link.MenuAction title="Block" icon="nosign" destructive onPress={handleBlock} />
<Link.Menu title="More" icon="ellipsis">
<Link.MenuAction title="Copy" icon="doc.on.doc" onPress={() => {}} />
<Link.MenuAction title="Delete" icon="trash" destructive onPress={() => {}} />
</Link.Menu>
</Link.Menu>
</Link>
Modal
<Stack.Screen name="modal" options={{ presentation: "modal" }} />
Sheet
<Stack.Screen
name="sheet"
options={{
presentation: "formSheet",
sheetGrabberVisible: true,
sheetAllowedDetents: [0.5, 1.0],
contentStyle: { backgroundColor: "transparent" }, // Liquid glass on iOS 26+
}}
/>
Native Tabs (SDK 54+)
Always prefer NativeTabs from 'expo-router/unstable-native-tabs':
import { NativeTabs, Icon, Label, Badge } from "expo-router/unstable-native-tabs";
export default function TabLayout() {
return (
<NativeTabs minimizeBehavior="onScrollDown">
<NativeTabs.Trigger name="index">
<Label>Home</Label>
<Icon sf="house.fill" />
<Badge>9+</Badge>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="settings">
<Icon sf="gear" />
<Label>Settings</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(search)" role="search">
<Label>Search</Label>
</NativeTabs.Trigger>
</NativeTabs>
);
}
NativeTabs Rules
- Include a trigger for each tab
-
namemust match route name exactly (including parentheses) - Place search tab last to combine with search bar
- Use
roleprop for common tab types:search,favorites,more, etc.
Icon Component
<Icon sf="house.fill" /> // SF Symbol only
<Icon sf="house.fill" drawable="ic_home" /> // With Android drawable
<Icon src={require('./icon.png')} /> // Custom image
<Icon sf={{ default: "house", selected: "house.fill" }} /> // State variants
Badge Component
<Badge>9+</Badge> // Numeric badge
<Badge /> // Dot indicator
SF Symbols (expo-symbols)
Use SF Symbols for native feel. Never use FontAwesome or Ionicons.
import { SymbolView } from "expo-symbols";
import { PlatformColor } from "react-native";
<SymbolView
tintColor={PlatformColor("label")}
resizeMode="scaleAspectFit"
name="square.and.arrow.down"
style={{ width: 16, height: 16 }}
/>
Common Icons
| Category | Icons |
|---|---|
| Navigation | house.fill, gear, magnifyingglass, plus, xmark, chevron.left/right |
| Media | play.fill, pause.fill, stop.fill, speaker.wave.2.fill |
| Social | heart, heart.fill, star, star.fill, person, person.fill |
| Actions | square.and.arrow.up (share), doc.on.doc (copy), trash (delete), pencil (edit) |
| Status | checkmark.circle.fill, xmark.circle.fill, exclamationmark.triangle, bell.fill |
Animated Symbols
<SymbolView
name="checkmark.circle"
animationSpec={{
effect: { type: "bounce", direction: "up" }
}}
/>
Effects: bounce, pulse, variableColor, scale
Symbol Weights
ultraLight, thin, light, regular, medium, semibold, bold, heavy, black
Common Route Structure
app/
_layout.tsx — <NativeTabs />
(index,search)/
_layout.tsx — <Stack />
index.tsx — Main list
search.tsx — Search view
// 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 - Shared stack for both tabs
import { Stack } from "expo-router/stack";
import { PlatformColor } from "react-native";
export default function Layout({ segment }) {
const screen = segment.match(/\((.*)\\)/)?.[1]!;
const titles = { index: "Items", search: "Search" };
return (
<Stack
screenOptions={{
headerTransparent: true,
headerShadowVisible: false,
headerLargeTitleShadowVisible: false,
headerLargeStyle: { backgroundColor: "transparent" },
headerTitleStyle: { color: PlatformColor("label") },
headerLargeTitle: true,
headerBlurEffect: "none",
headerBackButtonDisplayMode: "minimal",
}}
>
<Stack.Screen name={screen} options={{ title: titles[screen] }} />
<Stack.Screen name="i/[id]" options={{ headerLargeTitle: false }} />
</Stack>
);
}
Behavior
- Use expo-haptics conditionally on iOS for delightful experiences
- Use views with built-in haptics like
<Switch />and@react-native-community/datetimepicker - When a route belongs to a Stack, its first child should be a ScrollView with
contentInsetAdjustmentBehavior="automatic" - Prefer
headerSearchBarOptionsin Stack.Screen options to add a search bar - Use
<Text selectable />for data that could be copied - Format large numbers (1.4M, 38k)
- Never use intrinsic elements like 'img' or 'div' unless in webview or DOM component
Visual Effects
Blur Effects (expo-blur)
import { BlurView } from 'expo-blur';
<BlurView intensity={50} style={StyleSheet.absoluteFill} />
Liquid Glass (expo-glass-effect)
import { GlassView } from 'expo-glass-effect';
<GlassView style={styles.glass} cornerRadius={16} />
Validation Commands
npx tsc --noEmit # Type check
npx eslint . # Lint
npm test # Tests
npx expo-doctor # Doctor check
npx expo start # Start dev
Examples
Read the usage examples in the source repository.
View source ↗Source history
- Indexed on
- 2026-01-20
- Index updated
- 2026-03-03
- Branch
- main
Index and sync dates describe this directory. View the source repository for code history.
GitHub ↗chat Comments (0)
Sign in to join the discussion and leave a comment.
No comments yet. Be the first to share your thoughts!