expo-router
Navigation and routing for Expo Router. Covers file-based routes, groups and dynamic routes, folder organization, Link with previews and context menus, native Stack, page titles, modals and form sheets, NativeTabs, headers and toolbars, and header search bars.
Expo Router Navigation
Navigation and routing for Expo Router apps. For screen styling, colors, controls, media, and visual effects, use the expo-native-ui skill; for motion and gestures, use expo-animation.
References
Consult these resources as needed:
references/
route-structure.md Route conventions, dynamic routes, groups, folder organization
tabs.md NativeTabs, migration from JS tabs, iOS 26 features
toolbar-and-headers.md Stack headers and toolbar buttons, menus, search (iOS only)
form-sheet.md Form sheets in expo-router: configuration, footers and background interaction.
search.md Search bar with headers, useSearch hook, filtering patterns
zoom-transitions.md Apple Zoom: fluid zoom transitions with Link.AppleZoom (iOS 18+)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, and prefer aliases over relative imports for refactors.
Routes
See ./references/route-structure.md for detailed route conventions.
- Routes belong in the
appdirectory. - 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
Colorfromexpo-routerfor native semantic colors, not rawPlatformColor(type-safe, auto-adapts to light/dark). Seeexpo-native-uifor the full color palette pattern.- In SDK 56+, never import from
@react-navigation/*directly — useexpo-router/react-navigationinstead (covers@react-navigation/native,/core,/elements,/routers)
Behavior
- Prefer
Stack.SearchBarto add a search bar to a screen
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
_layout.tsxfiles to define stacks - Use Stack from 'expo-router/stack' for native navigation stacks
Page Title
Set the page title with Stack.Title:
<Stack.Title>Home</Stack.Title>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
contentStyle: { backgroundColor: "transparent" }makes the background liquid glass on iOS 26+.
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 } from "expo-router/unstable-native-tabs";
import { ThemeProvider, DarkTheme, DefaultTheme } from "expo-router/react-navigation";
import { useColorScheme } from "react-native";
export default function Layout() {
const colorScheme = useColorScheme();
return (
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
<NativeTabs>
<NativeTabs.Trigger name="(index)">
<NativeTabs.Trigger.Icon sf="list.dash" md="list" />
<NativeTabs.Trigger.Label>Items</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(search)" role="search" />
</NativeTabs>
</ThemeProvider>
);
}Create a shared group route so both tabs can push common screens:
// app/(index,search)/_layout.tsx
import { Stack } from "expo-router/stack";
import { colors } from "@/theme/colors";
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: colors.label },
headerLargeTitleEnabled: true,
headerBlurEffect: "none",
headerBackButtonDisplayMode: "minimal",
}}
>
<Stack.Screen name={screen} options={{ title: titles[screen] }} />
<Stack.Screen name="i/[id]" options={{ headerLargeTitleEnabled: false }} />
</Stack>
);
}headerLargeTitleEnabled is the SDK 56+ option name; older SDKs use headerLargeTitle, which is deprecated upstream.
Submitting Feedback
If you encounter errors, misleading or outdated information in this skill, report it so Expo can improve:
npx --yes submit-expo-feedback@latest --category skills --subject "expo-router" "<actionable feedback>"Only submit when you have something specific and actionable to report. Include as much relevant context as possible. If an AI agent repeatedly failed or the user had to take over an Expo task, load the expo-skill-feedback skill and follow its eval-candidate flow instead of reusing the command above.
SKILL.md
SKILL.md holds the skill's instructions; it is edited on the Instructions tab.
agents/openai.yaml
interface:
display_name: "Expo Router"
short_description: "Navigate and structure Expo Router apps: file-based routes, links, native stacks, modals, sheets, native tabs, headers, and search"
default_prompt: "Use $expo-router to structure file-based routes, wire up navigation with Link previews and context menus, configure native Stack screens, modals, form sheets, NativeTabs, headers, toolbars, and search bars."
references/form-sheet.md
Form Sheets in Expo Router
This skill covers implementing form sheets with footers using Expo Router's Stack navigator and react-native-screens.
Overview
Form sheets are modal presentations that appear as a card sliding up from the bottom of the screen. They're ideal for:
- Quick actions and confirmations
- Settings panels
- Login/signup flows
- Action sheets with custom content
Requirements:
- Expo Router Stack navigator
Basic Usage
Form Sheet with Footer
Configure the Stack.Screen with transparent backgrounds and sheet presentation:
// app/_layout.tsx
import { Stack } from "expo-router";
export default function Layout() {
return (
<Stack>
<Stack.Screen name="index" />
<Stack.Screen
name="about"
options={{
presentation: "formSheet",
sheetAllowedDetents: [0.25],
headerTransparent: true,
contentStyle: { backgroundColor: "transparent" },
sheetGrabberVisible: true,
}}
>
<Stack.Header style={{ backgroundColor: "transparent" }}></Stack.Header>
</Stack.Screen>
</Stack>
);
}Form Sheet Screen Content
Requires Expo SDK 55 or later.
Use flex: 1 to allow the content to fill available space, enabling footer positioning:
// app/about.tsx
import { View, Text, StyleSheet } from "react-native";
export default function AboutSheet() {
return (
<View style={styles.container}>
{/* Main content */}
<View style={styles.content}>
<Text>Sheet Content</Text>
</View>
{/* Footer - stays at bottom */}
<View style={styles.footer}>
<Text>Footer Content</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
flex: 1,
padding: 16,
},
footer: {
padding: 16,
},
});Formsheet with interactive content below
Use sheetLargestUndimmedDetentIndex (zero-indexed) to keep content behind the form sheet interactive — e.g. letting users pan a map beneath it. Setting it to 1 allows interaction at the first two detents but dims on the third.
// app/_layout.tsx
import { Stack } from 'expo-router';
export default function Layout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen
name="info-sheet"
options={{
presentation: "formSheet",
sheetAllowedDetents: [0.2, 0.5, 1.0],
sheetLargestUndimmedDetentIndex: 1,
/* other options */
}}
/>
</Stack>
)
}Key Options
| Option | Type | Description |
|---|---|---|
presentation |
string |
Set to 'formSheet' for sheet presentation |
sheetGrabberVisible |
boolean |
Shows the drag handle at the top of the sheet |
sheetAllowedDetents |
number[] |
Array of detent heights (0-1 range, e.g., [0.25] for 25%) |
headerTransparent |
boolean |
Makes header background transparent |
contentStyle |
object |
Style object for the screen content container |
title |
string |
Screen title (set to '' for no title) |
Common Detent Values
[0.25]- Quarter sheet (compact actions)[0.5]- Half sheet (medium content)[0.75]- Three-quarter sheet (detailed forms)[0.25, 0.5, 1]- Multiple stops (expandable sheet)
Complete Example
// _layout.tsx
import { Stack } from "expo-router";
export default function Layout() {
return (
<Stack>
<Stack.Screen name="index" options={{ title: "Home" }} />
<Stack.Screen
name="confirm"
options={{
contentStyle: { backgroundColor: "transparent" },
presentation: "formSheet",
title: "",
sheetGrabberVisible: true,
sheetAllowedDetents: [0.25],
headerTransparent: true,
}}
>
<Stack.Header style={{ backgroundColor: "transparent" }}>
<Stack.Header.Right />
</Stack.Header>
</Stack.Screen>
</Stack>
);
}// app/confirm.tsx
import { View, Text, Pressable, StyleSheet } from "react-native";
import { router } from "expo-router";
export default function ConfirmSheet() {
return (
<View style={styles.container}>
<View style={styles.content}>
<Text style={styles.title}>Confirm Action</Text>
<Text style={styles.description}>
Are you sure you want to proceed?
</Text>
</View>
<View style={styles.footer}>
<Pressable style={styles.cancelButton} onPress={() => router.back()}>
<Text style={styles.cancelText}>Cancel</Text>
</Pressable>
<Pressable style={styles.confirmButton} onPress={() => router.back()}>
<Text style={styles.confirmText}>Confirm</Text>
</Pressable>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
flex: 1,
padding: 20,
alignItems: "center",
justifyContent: "center",
},
title: {
fontSize: 18,
fontWeight: "600",
marginBottom: 8,
},
description: {
fontSize: 14,
color: "#666",
textAlign: "center",
},
footer: {
flexDirection: "row",
padding: 16,
gap: 12,
},
cancelButton: {
flex: 1,
padding: 14,
borderRadius: 10,
backgroundColor: "#f0f0f0",
alignItems: "center",
},
cancelText: {
fontSize: 16,
fontWeight: "500",
},
confirmButton: {
flex: 1,
padding: 14,
borderRadius: 10,
backgroundColor: "#007AFF",
alignItems: "center",
},
confirmText: {
fontSize: 16,
fontWeight: "500",
color: "white",
},
});Troubleshooting
Content not filling sheet
Make sure the root View uses flex: 1:
<View style={{ flex: 1 }}>{/* content */}</View>Sheet background showing through
Set contentStyle: { backgroundColor: 'transparent' } in options and style your content container with the desired background color instead.
references/route-structure.md
Route Structure
File Conventions
- Routes belong in the
appdirectory - Use
[]for dynamic routes, e.g.[id].tsx - Routes can never be named
(foo).tsx- use(foo)/index.tsxinstead - Use
(group)routes to simplify the public URL structure - NEVER co-locate components, types, or utilities in the app directory - these should be in separate directories like
components/,utils/, etc. - The app directory should only contain route and
_layoutfiles; every file should export a default component - Ensure the app always has a route that matches "/" so the app is never blank
- ALWAYS use
_layout.tsxfiles to define stacks
Dynamic Routes
Use square brackets for dynamic segments:
app/
users/
[id].tsx # Matches /users/123, /users/abc
[id]/
posts.tsx # Matches /users/123/postsCatch-All Routes
Use [...slug] for catch-all routes:
app/
docs/
[...slug].tsx # Matches /docs/a, /docs/a/b, /docs/a/b/cQuery Parameters
Access query parameters with the useLocalSearchParams hook:
import { useLocalSearchParams } from "expo-router";
function Page() {
const { id } = useLocalSearchParams<{ id: string }>();
}For dynamic routes, the parameter name matches the file name:
[id].tsx→useLocalSearchParams<{ id: string }>()[slug].tsx→useLocalSearchParams<{ slug: string }>()
Pathname
Access the current pathname with the usePathname hook:
import { usePathname } from "expo-router";
function Component() {
const pathname = usePathname(); // e.g. "/users/123"
}Group Routes
Use parentheses for groups that don't affect the URL:
app/
(auth)/
login.tsx # URL: /login
register.tsx # URL: /register
(main)/
index.tsx # URL: /
settings.tsx # URL: /settingsGroups are useful for:
- Organizing related routes
- Applying different layouts to route groups
- Keeping URLs clean
Stacks and Tabs Structure
When an app has tabs, the header and title should be set in a Stack that is nested INSIDE each tab. This allows tabs to have their own headers and distinct histories. The root layout should often not have a header.
- Set the 'headerShown' option to false on the tab layout
- Use (group) routes to simplify the public URL structure
- You may need to delete or refactor existing routes to fit this structure
Example structure:
app/
_layout.tsx — <Tabs />
(home)/
_layout.tsx — <Stack />
index.tsx — <ScrollView />
(settings)/
_layout.tsx — <Stack />
index.tsx — <ScrollView />
(home,settings)/
info.tsx — <ScrollView /> (shared across tabs)Array Routes for Multiple Stacks
Use array routes '(index,settings)' to create multiple stacks. This is useful for tabs that need to share screens across stacks.
app/
_layout.tsx — <Tabs />
(index,settings)/
_layout.tsx — <Stack />
index.tsx — <ScrollView />
settings.tsx — <ScrollView />This requires a specialized layout with explicit anchor routes:
// app/(index,settings)/_layout.tsx
import { useMemo } from "react";
import Stack from "expo-router/stack";
export const unstable_settings = {
index: { anchor: "index" },
settings: { anchor: "settings" },
};
export default function Layout({ segment }: { segment: string }) {
const screen = segment.match(/\((.*)\)/)?.[1]!;
const options = useMemo(() => {
switch (screen) {
case "index":
return { headerRight: () => <></> };
default:
return {};
}
}, [screen]);
return (
<Stack>
<Stack.Screen name={screen} options={options} />
</Stack>
);
}Complete App Structure Example
app/
_layout.tsx — <NativeTabs />
(index,search)/
_layout.tsx — <Stack />
index.tsx — Main list
search.tsx — Search view
i/[id].tsx — Detail page
components/
theme.tsx
list.tsx
utils/
storage.ts
use-search.tsLayout Files
Every directory can have a _layout.tsx file that wraps all routes in that directory:
// app/_layout.tsx
import { Stack } from "expo-router/stack";
export default function RootLayout() {
return <Stack />;
}// app/(tabs)/_layout.tsx
import { NativeTabs, Icon, Label } from "expo-router/unstable-native-tabs";
export default function TabLayout() {
return (
<NativeTabs>
<NativeTabs.Trigger name="index">
<Label>Home</Label>
<Icon sf="house.fill" />
</NativeTabs.Trigger>
</NativeTabs>
);
}Route Settings
Export unstable_settings to configure route behavior:
export const unstable_settings = {
anchor: "index",
};initialRouteNamewas renamed toanchorin v4
Not Found Routes
Create a +not-found.tsx file to handle unmatched routes:
// app/+not-found.tsx
import { Link } from "expo-router";
import { View, Text } from "react-native";
export default function NotFound() {
return (
<View>
<Text>Page not found</Text>
<Link href="/">Go home</Link>
</View>
);
}references/search.md
Search
Header Search Bar
Add a search bar to the stack header with headerSearchBarOptions:
<Stack.Screen
name="index"
options={{
headerSearchBarOptions: {
placeholder: "Search",
onChangeText: (event) => console.log(event.nativeEvent.text),
},
}}
/>Options
headerSearchBarOptions: {
// Placeholder text
placeholder: "Search items...",
// Auto-capitalize behavior
autoCapitalize: "none",
// Input type
inputType: "text", // "text" | "phone" | "number" | "email"
// Cancel button text (iOS)
cancelButtonText: "Cancel",
// Hide when scrolling (iOS)
hideWhenScrolling: true,
// Hide navigation bar during search (iOS)
hideNavigationBar: true,
// Obscure background during search (iOS)
obscureBackground: true,
// Placement
placement: "automatic", // "automatic" | "inline" | "stacked"
// Callbacks
onChangeText: (event) => {},
onSearchButtonPress: (event) => {},
onCancelButtonPress: (event) => {},
onFocus: () => {},
onBlur: () => {},
}useSearch Hook
Reusable hook for search state management:
import { useEffect, useState } from "react";
import { useNavigation } from "expo-router";
export function useSearch(options: any = {}) {
const [search, setSearch] = useState("");
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
headerShown: true,
headerSearchBarOptions: {
...options,
onChangeText(e: any) {
setSearch(e.nativeEvent.text);
options.onChangeText?.(e);
},
onSearchButtonPress(e: any) {
setSearch(e.nativeEvent.text);
options.onSearchButtonPress?.(e);
},
onCancelButtonPress(e: any) {
setSearch("");
options.onCancelButtonPress?.(e);
},
},
});
}, [options, navigation]);
return search;
}Usage
function SearchScreen() {
const search = useSearch({ placeholder: "Search items..." });
const filteredItems = items.filter(item =>
item.name.toLowerCase().includes(search.toLowerCase())
);
return (
<FlatList
data={filteredItems}
renderItem={({ item }) => <ItemRow item={item} />}
/>
);
}Filtering Patterns
Simple Text Filter
const filtered = items.filter(item =>
item.name.toLowerCase().includes(search.toLowerCase())
);Multiple Fields
const filtered = items.filter(item => {
const query = search.toLowerCase();
return (
item.name.toLowerCase().includes(query) ||
item.description.toLowerCase().includes(query) ||
item.tags.some(tag => tag.toLowerCase().includes(query))
);
});Debounced Search
For expensive filtering or API calls:
import { useState, useEffect, useMemo } from "react";
function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
function SearchScreen() {
const search = useSearch();
const debouncedSearch = useDebounce(search, 300);
const filteredItems = useMemo(() =>
items.filter(item =>
item.name.toLowerCase().includes(debouncedSearch.toLowerCase())
),
[debouncedSearch]
);
return <FlatList data={filteredItems} />;
}Search with Native Tabs
When using NativeTabs with a search role, the search bar integrates with the tab bar:
// app/_layout.tsx
<NativeTabs>
<NativeTabs.Trigger name="(home)">
<Label>Home</Label>
<Icon sf="house.fill" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(search)" role="search">
<Label>Search</Label>
</NativeTabs.Trigger>
</NativeTabs>// app/(search)/_layout.tsx
<Stack>
<Stack.Screen
name="index"
options={{
headerSearchBarOptions: {
placeholder: "Search...",
onChangeText: (e) => setSearch(e.nativeEvent.text),
},
}}
/>
</Stack>Empty States
Show appropriate UI when search returns no results:
function SearchResults({ search, items }) {
const filtered = items.filter(/* ... */);
if (search && filtered.length === 0) {
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<Text style={{ color: colors.secondaryLabel }}>
No results for "{search}"
</Text>
</View>
);
}
return <FlatList data={filtered} />;
}Search Suggestions
Show recent searches or suggestions:
function SearchScreen() {
const search = useSearch();
const [recentSearches, setRecentSearches] = useState<string[]>([]);
if (!search && recentSearches.length > 0) {
return (
<View>
<Text style={{ color: colors.secondaryLabel }}>
Recent Searches
</Text>
{recentSearches.map((term) => (
<Pressable key={term} onPress={() => /* apply search */}>
<Text>{term}</Text>
</Pressable>
))}
</View>
);
}
return <SearchResults search={search} />;
}references/tabs.md
Native Tabs
Always prefer NativeTabs from 'expo-router/unstable-native-tabs' for the best iOS experience.
SDK 54+. SDK 55 recommended.
SDK Compatibility
| Aspect | SDK 54 | SDK 55+ |
|---|---|---|
| Import | import { NativeTabs, Icon, Label, Badge, VectorIcon } |
import { NativeTabs } only |
| Icon | <Icon sf="house.fill" /> |
<NativeTabs.Trigger.Icon sf="house.fill" /> |
| Label | <Label>Home</Label> |
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label> |
| Badge | <Badge>9+</Badge> |
<NativeTabs.Trigger.Badge>9+</NativeTabs.Trigger.Badge> |
| Android icons | drawable prop |
md prop (Material Symbols) |
All examples below use SDK 55 syntax. For SDK 54, replace NativeTabs.Trigger.Icon/Label/Badge with standalone Icon, Label, Badge imports.
Basic Usage
import { NativeTabs } from "expo-router/unstable-native-tabs";
export default function TabLayout() {
return (
<NativeTabs minimizeBehavior="onScrollDown">
<NativeTabs.Trigger name="index">
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Badge>9+</NativeTabs.Trigger.Badge>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="settings">
<NativeTabs.Trigger.Icon sf="gear" md="settings" />
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(search)" role="search">
<NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
</NativeTabs>
);
}Rules
- You must include a trigger for each tab
- The
NativeTabs.Trigger'name' must match the route name, including parentheses (e.g.<NativeTabs.Trigger name="(search)">) - Prefer search tab to be last in the list so it can combine with the search bar
- Use the 'role' prop for common tab types
- Tabs must be static — no dynamic addition/removal at runtime (remounts navigator, loses state)
Platform Features
Native Tabs use platform-specific tab bar implementations:
- iOS 26+: Liquid glass effects with system-native appearance
- Android: Material 3 bottom navigation
- Better performance and native feel
Icon Component
// SF Symbol (iOS) + Material Symbol (Android)
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
// State variants
<NativeTabs.Trigger.Icon sf={{ default: "house", selected: "house.fill" }} md="home" />
// Custom image
<NativeTabs.Trigger.Icon src={require('./icon.png')} />
// Xcode asset catalog — iOS only (SDK 55+)
<NativeTabs.Trigger.Icon xcasset="home-icon" />
<NativeTabs.Trigger.Icon xcasset={{ default: "home-outline", selected: "home-filled" }} />
// Rendering mode — iOS only (SDK 55+)
<NativeTabs.Trigger.Icon src={require('./icon.png')} renderingMode="template" />
<NativeTabs.Trigger.Icon src={require('./gradient.png')} renderingMode="original" />renderingMode: "template" applies tint color (single-color icons), "original" preserves source colors (gradients). Android always uses original.
Label & Badge
// Label
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Label hidden>Home</NativeTabs.Trigger.Label> {/* icon-only tab */}
// Badge
<NativeTabs.Trigger.Badge>9+</NativeTabs.Trigger.Badge>
<NativeTabs.Trigger.Badge /> {/* dot indicator */}iOS 26 Features
Liquid Glass Tab Bar
The tab bar automatically adopts liquid glass appearance on iOS 26+.
Minimize on Scroll
<NativeTabs minimizeBehavior="onScrollDown">Search Tab
<NativeTabs.Trigger name="(search)" role="search">
<NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>Note: Place search tab last for best UX.
Role Prop
Use semantic roles for special tab types:
<NativeTabs.Trigger name="search" role="search" />
<NativeTabs.Trigger name="favorites" role="favorites" />
<NativeTabs.Trigger name="more" role="more" />Available roles: search | more | favorites | bookmarks | contacts | downloads | featured | history | mostRecent | mostViewed | recents | topRated
Customization
Tint Color
<NativeTabs tintColor="#007AFF">Dynamic Colors (iOS)
Use DynamicColorIOS for colors that adapt to liquid glass:
import { DynamicColorIOS, Platform } from 'react-native';
const adaptiveBlue = Platform.select({
ios: DynamicColorIOS({ light: '#007AFF', dark: '#0A84FF' }),
default: '#007AFF',
});
<NativeTabs tintColor={adaptiveBlue}>Conditional Tabs
<NativeTabs.Trigger name="admin" hidden={!isAdmin}>
<NativeTabs.Trigger.Label>Admin</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="shield.fill" md="shield" />
</NativeTabs.Trigger>Don't hide the tabs when they are visible - toggling visibility remounts the navigator; Do it only during the initial render.
Note: Hidden tabs cannot be navigated to!
Behavior Options
<NativeTabs.Trigger
name="home"
disablePopToTop // Don't pop stack when tapping active tab
disableScrollToTop // Don't scroll to top when tapping active tab
disableAutomaticContentInsets // Opt out of automatic safe area insets (SDK 55+)
>Hidden Tab Bar (SDK 55+)
Use hidden prop on NativeTabs to hide the entire tab bar dynamically:
<NativeTabs hidden={isTabBarHidden}>{/* triggers */}</NativeTabs>Bottom Accessory (SDK 55+)
NativeTabs.BottomAccessory renders content above the tab bar (iOS 26+). Uses usePlacement() to adapt between 'regular' and 'inline' layouts.
Important: Two instances render simultaneously — store state outside the component (props, context, or external store).
import { NativeTabs } from "expo-router/unstable-native-tabs";
import { useState } from "react";
import { Pressable, Text, View } from "react-native";
function MiniPlayer({
isPlaying,
onToggle,
}: {
isPlaying: boolean;
onToggle: () => void;
}) {
const placement = NativeTabs.BottomAccessory.usePlacement();
if (placement === "inline") {
return (
<Pressable onPress={onToggle}>
<SymbolView name={isPlaying ? "pause.fill" : "play.fill"} />
</Pressable>
);
}
return <View>{/* full player UI */}</View>;
}
export default function TabLayout() {
const [isPlaying, setIsPlaying] = useState(false);
return (
<NativeTabs>
<NativeTabs.BottomAccessory>
<MiniPlayer
isPlaying={isPlaying}
onToggle={() => setIsPlaying(!isPlaying)}
/>
</NativeTabs.BottomAccessory>
<NativeTabs.Trigger name="index">
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
</NativeTabs>
);
}Safe Area Handling (SDK 55+)
SDK 55 handles safe areas automatically:
- Android: Content wrapped in SafeAreaView (bottom inset)
- iOS: First ScrollView gets automatic
contentInsetAdjustmentBehavior
To opt out per-tab, use disableAutomaticContentInsets and manage manually:
<NativeTabs.Trigger name="index" disableAutomaticContentInsets>
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>// In the screen
import { SafeAreaView } from "react-native-screens/experimental";
export default function HomeScreen() {
return (
<SafeAreaView edges={{ bottom: true }} style={{ flex: 1 }}>
{/* content */}
</SafeAreaView>
);
}Using Vector Icons
If you must use @expo/vector-icons instead of SF Symbols:
import { NativeTabs } from "expo-router/unstable-native-tabs";
import Ionicons from "@expo/vector-icons/Ionicons";
<NativeTabs.Trigger name="home">
<NativeTabs.Trigger.VectorIcon vector={Ionicons} name="home" />
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>Prefer SF Symbols + md prop over vector icons for native feel.
If you are using SDK 55 and later use the md prop to specify Material Symbols used on Android.
Structure with Stacks
Native tabs don't render headers. Nest Stacks inside each tab for navigation headers:
// app/(tabs)/_layout.tsx
import { NativeTabs } from "expo-router/unstable-native-tabs";
export default function TabLayout() {
return (
<NativeTabs>
<NativeTabs.Trigger name="(home)">
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
</NativeTabs.Trigger>
</NativeTabs>
);
}
// app/(tabs)/(home)/_layout.tsx
import Stack from "expo-router/stack";
export default function HomeStack() {
return (
<Stack>
<Stack.Screen
name="index"
options={{ title: "Home", headerLargeTitleEnabled: true }}
/>
<Stack.Screen name="details" options={{ title: "Details" }} />
</Stack>
);
}Custom Web Layout
Use platform-specific files for separate native and web tab layouts:
app/
_layout.tsx # NativeTabs for iOS/Android
_layout.web.tsx # Headless tabs for web (expo-router/ui)Or extract to a component: components/app-tabs.tsx + components/app-tabs.web.tsx.
Migration from JS Tabs
Before (JS Tabs)
import { Tabs } from "expo-router";
<Tabs>
<Tabs.Screen
name="index"
options={{
title: "Home",
tabBarIcon: ({ color }) => <IconSymbol name="house.fill" color={color} />,
tabBarBadge: 3,
}}
/>
</Tabs>;After (Native Tabs)
import { NativeTabs } from "expo-router/unstable-native-tabs";
<NativeTabs>
<NativeTabs.Trigger name="index">
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
<NativeTabs.Trigger.Badge>3</NativeTabs.Trigger.Badge>
</NativeTabs.Trigger>
</NativeTabs>;Key Differences
| JS Tabs | Native Tabs |
|---|---|
<Tabs.Screen> |
<NativeTabs.Trigger> |
options={{ title }} |
<NativeTabs.Trigger.Label> |
options={{ tabBarIcon }} |
<NativeTabs.Trigger.Icon> |
tabBarBadge option |
<NativeTabs.Trigger.Badge> |
| Props-based API | Component-based API |
| Headers built-in | Nest <Stack> for headers |
Limitations
- Android: Maximum 5 tabs (Material Design constraint)
- Nesting: Native tabs cannot nest inside other native tabs
- Tab bar height: Cannot be measured programmatically
- FlatList transparency: Use
disableTransparentOnScrollEdgeto fix issues - Dynamic tabs: Tabs must be static; changes remount navigator and lose state
Keyboard Handling (Android)
Configure in app.json:
{
"expo": {
"android": {
"softwareKeyboardLayoutMode": "resize"
}
}
}Common Issues
- Icons not showing on Android: Add
mdprop (SDK 55) or use VectorIcon - Headers missing: Nest a Stack inside each tab group
- Trigger name mismatch:
namemust match exact route name including parentheses - Badge not visible: Badge must be a child of Trigger, not a prop
- Tab bar transparent on iOS 18 and earlier: If the screen uses a
ScrollVieworFlatList, make sure it is the first opaque child of the screen component. If it needs to be wrapped in anotherView, ensure the wrapper usescollapsable={false}. If the screen does not use aScrollVieworFlatList, setdisableTransparentOnScrollEdgetotruein theNativeTabs.Triggeroptions, to make the tab bar opaque. - Scroll to top not working: Ensure
disableScrollToTopis not set on the active tab's Trigger andScrollViewis the first child of the screen component. - Header buttons flicker when navigating between tabs: Make sure the app is wrapped in a
ThemeProvider
import {
ThemeProvider,
DarkTheme,
DefaultTheme,
} from "expo-router/react-navigation";
import { useColorScheme } from "react-native";
import { Stack } from "expo-router";
export default function Layout() {
const colorScheme = useColorScheme();
return (
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
<Stack />
</ThemeProvider>
);
}If the app only uses a light or dark theme, you can directly pass DarkTheme or DefaultTheme to ThemeProvider without checking the color scheme.
import { ThemeProvider, DarkTheme } from "expo-router/react-navigation";
import { Stack } from "expo-router";
export default function Layout() {
return (
<ThemeProvider value={DarkTheme}>
<Stack />
</ThemeProvider>
);
}references/toolbar-and-headers.md
Toolbars and headers
Add native iOS toolbar items to Stack screens. Items can be placed in the header (left/right) or in a bottom toolbar area.
Important: iOS only. Available in Expo SDK 55+.
Notes app example
import { Stack } from "expo-router";
import { ScrollView } from "react-native";
export default function FoldersScreen() {
return (
<>
{/* ScrollView must be the first child of the screen */}
<ScrollView
style={{ flex: 1 }}
contentInsetAdjustmentBehavior="automatic"
>
{/* Screen content */}
</ScrollView>
<Stack.Screen.Title large>Folders</Stack.Screen.Title>
<Stack.SearchBar placeholder="Search" onChangeText={() => {}} />
{/* Header toolbar - right side */}
<Stack.Toolbar placement="right">
<Stack.Toolbar.Button icon="folder.badge.plus" onPress={() => {}} />
<Stack.Toolbar.Button onPress={() => {}}>Edit</Stack.Toolbar.Button>
</Stack.Toolbar>
{/* Bottom toolbar */}
<Stack.Toolbar placement="bottom">
<Stack.Toolbar.SearchBarSlot />
<Stack.Toolbar.Button
icon="square.and.pencil"
onPress={() => {}}
separateBackground
/>
</Stack.Toolbar>
</>
);
}Mail inbox example
import { Color, Stack } from "expo-router";
import { useState } from "react";
import { ScrollView, Text, View } from "react-native";
export default function InboxScreen() {
const [isFilterOpen, setIsFilterOpen] = useState(false);
return (
<>
<ScrollView
style={{ flex: 1 }}
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{ paddingHorizontal: 16 }}
>
{/* Screen content */}
</ScrollView>
<Stack.Screen options={{ headerTransparent: true }} />
<Stack.Screen.Title>Inbox</Stack.Screen.Title>
<Stack.SearchBar placeholder="Search" onChangeText={() => {}} />
{/* Header toolbar - right side */}
<Stack.Toolbar placement="right">
<Stack.Toolbar.Button onPress={() => {}}>Select</Stack.Toolbar.Button>
<Stack.Toolbar.Menu icon="ellipsis">
<Stack.Toolbar.Menu inline>
<Stack.Toolbar.Menu inline title="Sort By">
<Stack.Toolbar.MenuAction isOn>
Categories
</Stack.Toolbar.MenuAction>
<Stack.Toolbar.MenuAction>List</Stack.Toolbar.MenuAction>
</Stack.Toolbar.Menu>
<Stack.Toolbar.MenuAction icon="info.circle">
About categories
</Stack.Toolbar.MenuAction>
</Stack.Toolbar.Menu>
<Stack.Toolbar.MenuAction icon="person.circle">
Show Contact Photos
</Stack.Toolbar.MenuAction>
</Stack.Toolbar.Menu>
</Stack.Toolbar>
{/* Bottom toolbar */}
<Stack.Toolbar placement="bottom">
<Stack.Toolbar.Button
icon="line.3.horizontal.decrease"
selected={isFilterOpen}
onPress={() => setIsFilterOpen((prev) => !prev)}
/>
<Stack.Toolbar.View hidden={!isFilterOpen}>
<View style={{ width: 70, height: 32, justifyContent: "center" }}>
<Text style={{ fontSize: 12, fontWeight: 700 }}>Filter by</Text>
<Text
style={{
fontSize: 12,
fontWeight: 700,
color: Color.ios.systemBlue,
}}
>
Unread
</Text>
</View>
</Stack.Toolbar.View>
<Stack.Toolbar.Spacer />
<Stack.Toolbar.SearchBarSlot />
<Stack.Toolbar.Button
icon="square.and.pencil"
onPress={() => {}}
separateBackground
/>
</Stack.Toolbar>
</>
);
}Placement
"left"- Header left"right"- Header right"bottom"(default) - Bottom toolbar
Components
Button
- Icon button:
<Stack.Toolbar.Button icon="star.fill" onPress={() => {}} /> - Text button:
<Stack.Toolbar.Button onPress={() => {}}>Done</Stack.Toolbar.Button>
Props: icon, image, onPress, disabled, hidden, variant ("plain" | "done" | "prominent"), tintColor
Menu
Dropdown menu for grouping actions.
<Stack.Toolbar.Menu icon="ellipsis">
<Stack.Toolbar.Menu inline>
<Stack.Toolbar.MenuAction>Sort by Recently Added</Stack.Toolbar.MenuAction>
<Stack.Toolbar.MenuAction isOn>
Sort by Date Captured
</Stack.Toolbar.MenuAction>
</Stack.Toolbar.Menu>
<Stack.Toolbar.Menu title="Filter">
<Stack.Toolbar.Menu inline>
<Stack.Toolbar.MenuAction isOn icon="square.grid.2x2">
All Items
</Stack.Toolbar.MenuAction>
</Stack.Toolbar.Menu>
<Stack.Toolbar.MenuAction icon="heart">Favorites</Stack.Toolbar.MenuAction>
<Stack.Toolbar.MenuAction icon="photo">Photos</Stack.Toolbar.MenuAction>
<Stack.Toolbar.MenuAction icon="video">Videos</Stack.Toolbar.MenuAction>
</Stack.Toolbar.Menu>
</Stack.Toolbar.Menu>Menu Props: All Button props plus title, inline, palette, elementSize ("small" | "medium" | "large")
MenuAction Props: icon, onPress, isOn, destructive, disabled, subtitle
When creating a palette with dividers, use inline combined with elementSize="small". palette will not apply dividers on iOS 26.
Spacer
<Stack.Toolbar.Spacer /> // Bottom toolbar - flexible
<Stack.Toolbar.Spacer width={16} /> // Header - requires explicit widthView
Embed custom React Native components. When adding a custom view make sure that there is only a single child with explicit width and height.
<Stack.Toolbar.View>
<View style={{ width: 70, height: 32, justifyContent: "center" }}>
<Text style={{ fontSize: 12, fontWeight: 700 }}>Filter by</Text>
</View>
</Stack.Toolbar.View>You can pass custom components to views as well:
function CustomFilterView() {
return (
<View style={{ width: 70, height: 32, justifyContent: "center" }}>
<Text style={{ fontSize: 12, fontWeight: 700 }}>Filter by</Text>
</View>
);
}
...
<Stack.Toolbar.View>
<CustomFilterView />
</Stack.Toolbar.View>Recommendations
- When creating more complex headers, extract them to a single component
export default function Page() {
return (
<>
<ScrollView>{/* Screen content */}</ScrollView>
<InboxHeader />
</>
);
}
function InboxHeader() {
return (
<>
<Stack.Screen.Title>Inbox</Stack.Screen.Title>
<Stack.SearchBar placeholder="Search" onChangeText={() => {}} />
<Stack.Toolbar placement="right">{/* Toolbar buttons */}</Stack.Toolbar>
</>
);
}- When using
Stack.Toolbar, make sure that allStack.Toolbar.*components are wrapped insideStack.Toolbarcomponent.
This will not work:
function Buttons() {
return (
<>
<Stack.Toolbar.Button icon="star.fill" onPress={() => {}} />
<Stack.Toolbar.Button onPress={() => {}}>Done</Stack.Toolbar.Button>
</>
);
}
function Page() {
return (
<>
<ScrollView>{/* Screen content */}</ScrollView>
<Stack.Toolbar placement="right">
<Buttons /> {/* ❌ This will NOT work */}
</Stack.Toolbar>
</>
);
}This will work:
function ToolbarWithButtons() {
return (
<Stack.Toolbar>
<Stack.Toolbar.Button icon="star.fill" onPress={() => {}} />
<Stack.Toolbar.Button onPress={() => {}}>Done</Stack.Toolbar.Button>
</Stack.Toolbar>
);
}
function Page() {
return (
<>
<ScrollView>{/* Screen content */}</ScrollView>
<ToolbarWithButtons /> {/* ✅ This will work */}
</>
);
}Limitations
- iOS only
placement="bottom"can only be used inside screen components (not in layout files)Stack.Toolbar.Badgeonly works withplacement="left"or"right"- Header Spacers require explicit
width
Reference
Docs https://docs.expo.dev/versions/unversioned/sdk/router - read to see the full API.
references/zoom-transitions.md
Apple Zoom Transitions
Fluid zoom transitions for navigating between screens. iOS 18+, Expo SDK 55+, Stack navigator only.
import { Link } from "expo-router";Basic Zoom
Use withAppleZoom on Link.Trigger to zoom the entire trigger element into the destination screen:
<Link href="/photo" asChild>
<Link.Trigger withAppleZoom>
<Pressable>
<Image
source={{ uri: "https://example.com/thumb.jpg" }}
style={{ width: 120, height: 120, borderRadius: 12 }}
/>
</Pressable>
</Link.Trigger>
</Link>Targeted Zoom with Link.AppleZoom
Wrap only the element that should animate. Siblings outside Link.AppleZoom are not part of the transition:
<Link href="/photo" asChild>
<Link.Trigger>
<Pressable style={{ alignItems: "center" }}>
<Link.AppleZoom>
<Image
source={{ uri: "https://example.com/thumb.jpg" }}
style={{ width: 200, aspectRatio: 4 / 3 }}
/>
</Link.AppleZoom>
<Text>Caption text (not zoomed)</Text>
</Pressable>
</Link.Trigger>
</Link>Link.AppleZoom accepts only a single child element.
Destination Target
Use Link.AppleZoomTarget on the destination screen to align the zoom animation to a specific element:
// Destination screen (e.g., app/photo.tsx)
import { Link } from "expo-router";
export default function PhotoScreen() {
return (
<View style={{ flex: 1 }}>
<Link.AppleZoomTarget>
<Image
source={{ uri: "https://example.com/full.jpg" }}
style={{ width: "100%", aspectRatio: 4 / 3 }}
/>
</Link.AppleZoomTarget>
<Text>Photo details below</Text>
</View>
);
}Without a target, the zoom animates to fill the entire destination screen.
Custom Alignment Rectangle
For manual control over where the zoom lands on the destination, use alignmentRect instead of Link.AppleZoomTarget:
<Link.AppleZoom alignmentRect={{ x: 0, y: 0, width: 200, height: 300 }}>
<Image source={{ uri: "https://example.com/thumb.jpg" }} />
</Link.AppleZoom>Coordinates are in the destination screen's coordinate space. Prefer Link.AppleZoomTarget when possible — use alignmentRect only when the target element isn't available as a React component.
Controlling Dismissal
Zoom screens support interactive dismissal gestures by default (pinch, swipe down when scrolled to top, swipe from leading edge). Use usePreventZoomTransitionDismissal on the destination screen to control this.
Disable all dismissal gestures
import { usePreventZoomTransitionDismissal } from "expo-router";
export default function PhotoScreen() {
usePreventZoomTransitionDismissal();
return <Image source={{ uri: "https://example.com/full.jpg" }} />;
}Restrict dismissal to a specific area
Use unstable_dismissalBoundsRect to prevent conflicts with scrollable content:
usePreventZoomTransitionDismissal({
unstable_dismissalBoundsRect: {
minX: 0,
minY: 0,
maxX: 300,
maxY: 300,
},
});This is useful when the destination contains a zoomable scroll view — the system gives that scroll view precedence over the dismiss gesture.
Combining with Link.Preview
Zoom transitions work alongside long-press previews:
<Link href="/photo" asChild>
<Link.Trigger withAppleZoom>
<Pressable>
<Image
source={{ uri: "https://example.com/thumb.jpg" }}
style={{ width: 120, height: 120 }}
/>
</Pressable>
</Link.Trigger>
<Link.Preview />
</Link>Best Practices
Good use cases:
- Thumbnail → full image (gallery, profile photos)
- Card → detail screen with similar visual content
- Source and destination with similar aspect ratios
Avoid:
- Skinny full-width list rows as zoom sources — the transition looks unnatural
- Mismatched aspect ratios between source and destination without
alignmentRect - Using zoom with sheets or popovers — only works in Stack navigator
- Hiding the navigation bar — known issues with header visibility during transitions
Tips:
- Always provide a close or back button — dismissal gestures are not discoverable
- If the destination has a zoomable scroll view, use
unstable_dismissalBoundsRectto avoid gesture conflicts - Source view doesn't need to match the tap target — only the
Link.AppleZoomwrapped element animates - When source is unavailable (e.g., scrolled off screen), the transition zooms from the center of the screen
References
- Expo Router Zoom Transitions: https://docs.expo.dev/router/advanced/zoom-transition/
- Link.AppleZoom API: https://docs.expo.dev/versions/v55.0.0/sdk/router/#linkapplezoom
- Apple UIKit Fluid Transitions: https://developer.apple.com/documentation/uikit/enhancing-your-app-with-fluid-transitions
Frontmatter written into each target's SKILL.md.
Common
No fields set for this target.