Skillshub building-native-ui
Complete guide for building beautiful apps with Expo Router. Covers fundamentals, styling, components, navigation, animations, patterns, and native tabs.
git clone https://github.com/ComeOnOliver/skillshub
T=$(mktemp -d) && git clone --depth=1 https://github.com/ComeOnOliver/skillshub "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/expo/skills/building-native-ui" ~/.claude/skills/comeonoliver-skillshub-building-native-ui-b67c95 && rm -rf "$T"
skills/expo/skills/building-native-ui/SKILL.mdExpo UI Guidelines
References
Consult these resources as needed:
references/ animations.md Reanimated: entering, exiting, layout, scroll-driven, gestures controls.md Native iOS: Switch, Slider, SegmentedControl, DateTimePicker, Picker form-sheet.md Form sheets in expo-router: configuration, footers and background interaction. gradients.md CSS gradients via experimental_backgroundImage (New Arch only) icons.md SF Symbols via expo-image (sf: source), names, animations, weights media.md Camera, audio, video, and file saving route-structure.md Route conventions, dynamic routes, groups, folder organization search.md Search bar with headers, useSearch hook, filtering patterns storage.md SQLite, AsyncStorage, SecureStore tabs.md NativeTabs, migration from JS tabs, iOS 26 features toolbar-and-headers.md Stack headers and toolbar buttons, menus, search (iOS only) visual-effects.md Blur (expo-blur) and liquid glass (expo-glass-effect) webgpu-three.md 3D graphics, games, GPU visualizations with WebGPU and Three.js zoom-transitions.md Apple Zoom: fluid zoom transitions with Link.AppleZoom (iOS 18+)
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
and scan the QR code with Expo Gonpx expo start - Check if features work: Test your app thoroughly in Expo Go
- Only create custom builds when required - see below
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
When Expo Go Works
Expo Go supports a huge range of features out of the box:
- All
packages (camera, location, notifications, etc.)expo-* - Expo Router navigation
- Most UI libraries (reanimated, gesture handler, etc.)
- Push notifications, deep links, and more
If you're unsure, try Expo Go first. Creating custom builds adds complexity, slower iteration, and requires Xcode/Android Studio setup.
Code Style
- Be cautious of unterminated strings. Ensure nested backticks are escaped; never forget to escape quotes correctly.
- Always use import statements at the top of the file.
- 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, and prefer aliases over relative imports for refactors.
Routes
See
./references/route-structure.md for detailed route conventions.
- Routes belong in the
directory.app - Never co-locate components, types, or utilities in the app directory. This is an anti-pattern.
- Ensure the app always has a route that matches "/", it may be inside a group route.
Library Preferences
- Never use modules removed from React Native such as Picker, WebView, SafeAreaView, or AsyncStorage
- Never use legacy expo-permissions
notexpo-audioexpo-av
notexpo-videoexpo-av
withexpo-image
for SF Symbols, notsource="sf:name"
orexpo-symbols@expo/vector-icons
not react-native SafeAreaViewreact-native-safe-area-context
notprocess.env.EXPO_OSPlatform.OS
notReact.useReact.useContext
Image component instead of intrinsic elementexpo-imageimg
for liquid glass backdropsexpo-glass-effect
Responsiveness
- Always wrap root component in a scroll view for responsiveness
- Use
instead of<ScrollView contentInsetAdjustmentBehavior="automatic" />
for smarter safe area insets<SafeAreaView>
should be applied to FlatList and SectionList as wellcontentInsetAdjustmentBehavior="automatic"- Use flexbox instead of Dimensions API
- ALWAYS prefer
overuseWindowDimensions
to measure screen sizeDimensions.get()
Behavior
- Use expo-haptics conditionally on iOS to make more delightful experiences
- Use views with built-in haptics like
from React Native and<Switch />@react-native-community/datetimepicker - When a route belongs to a Stack, its first child should almost always be a ScrollView with
setcontentInsetAdjustmentBehavior="automatic" - When adding a
to the page it should almost always be the first component inside the route componentScrollView - Prefer
in Stack.Screen options to add a search barheaderSearchBarOptions - Use the
prop on text containing data that could be copied<Text selectable /> - Consider formatting large numbers like 1.4M or 38k
- Never use intrinsic elements like 'img' or 'div' unless in a webview or Expo DOM component
Styling
Follow Apple Human Interface Guidelines.
General Styling Rules
- Prefer flex gap over margin and padding styles
- Prefer padding over margin where possible
- Always account for safe area, either with stack headers, tabs, or ScrollView/FlatList
contentInsetAdjustmentBehavior="automatic" - Ensure both top and bottom safe area insets are accounted for
- Inline styles not StyleSheet.create unless reusing styles is faster
- Add entering and exiting animations for state changes
- Use
for rounded corners unless creating a capsule shape{ borderCurve: 'continuous' } - ALWAYS use a navigation stack title instead of a custom text element on the page
- When padding a ScrollView, use
padding and gap instead of padding on the ScrollView itself (reduces clipping)contentContainerStyle - CSS and Tailwind are not supported - use inline styles
Text Styling
- Add the
prop to everyselectable
element displaying important data or error messages<Text/> - Counters should use
for alignment{ fontVariant: 'tabular-nums' }
Shadows
Use CSS
boxShadow style prop. NEVER use legacy React Native shadow or elevation styles.
<View style={{ boxShadow: "0 1px 2px rgba(0, 0, 0, 0.05)" }} />
'inset' shadows are supported.
Navigation
Link
Use
<Link href="/path" /> from 'expo-router' for navigation between routes.
import { Link } from 'expo-router'; // Basic link <Link href="/path" /> // Wrapping custom components <Link href="/path" asChild> <Pressable>...</Pressable> </Link>
Whenever possible, include a
<Link.Preview> to follow iOS conventions. Add context menus and previews frequently to enhance navigation.
Stack
- ALWAYS use
files to define stacks_layout.tsx - Use Stack from 'expo-router/stack' for native navigation stacks
Page Title
Set the page title in Stack.Screen options:
<Stack.Screen options={{ title: "Home" }} />
Context Menus
Add long press context menus to Link components:
import { Link } from "expo-router"; <Link href="/settings" asChild> <Link.Trigger> <Pressable> <Card /> </Pressable> </Link.Trigger> <Link.Menu> <Link.MenuAction title="Share" icon="square.and.arrow.up" onPress={handleSharePress} /> <Link.MenuAction title="Block" icon="nosign" destructive onPress={handleBlockPress} /> <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>;
Link Previews
Use link previews frequently to enhance navigation:
<Link href="/settings"> <Link.Trigger> <Pressable> <Card /> </Pressable> </Link.Trigger> <Link.Preview /> </Link>
Link preview can be used with context menus.
Modal
Present a screen as a modal:
<Stack.Screen name="modal" options={{ presentation: "modal" }} />
Prefer this to building a custom modal component.
Sheet
Present a screen as a dynamic form sheet:
<Stack.Screen name="sheet" options={{ presentation: "formSheet", sheetGrabberVisible: true, sheetAllowedDetents: [0.5, 1.0], contentStyle: { backgroundColor: "transparent" }, }} />
- Using
makes the background liquid glass on iOS 26+.contentStyle: { backgroundColor: "transparent" }
Common route structure
A standard app layout with tabs and stacks inside each tab:
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"; import { Theme } from "../components/theme"; export default function Layout() { return ( <Theme> <NativeTabs> <NativeTabs.Trigger name="(index)"> <Icon sf="list.dash" /> <Label>Items</Label> </NativeTabs.Trigger> <NativeTabs.Trigger name="(search)" role="search" /> </NativeTabs> </Theme> ); }
Create a shared group route so both tabs can push common screens:
// app/(index,search)/_layout.tsx import { Stack } from "expo-router/stack"; import { PlatformColor } from "react-native"; export default function Layout({ segment }) { const screen = segment.match(/\((.*)\)/)?.[1]!; const titles: Record<string, string> = { 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> ); }