# Navigation patterns

## Stack

Learn how to use the Stack navigator in Expo Router.

<VideoBoxLink
  videoId="izZv6a99Roo"
  title="Using a Stack Navigator with Expo Router"
  description="Navigate between screens, pass params between screens, create dynamic routes, and configure the screen titles and animations."
  className="mb-6"
/>
A stack navigator is the foundational way of navigating between routes in an app. On Android, a stacked route animates on top of the current screen. On iOS, a stacked route animates from the right. Expo Router provides a `Stack` navigation component that creates a navigation stack and allows you to add new routes in your app.
This guide provides information on how you can create a `Stack` navigator in your project and customize an individual route's options and header.
## Get started
You can use file-based routing to create a stack navigator. Here's an example file structure:
```
âââ app/
    âââ _layout.tsx
    âââ index.tsx
    âââ details.tsx
```
This file structure produces a layout where the `index` route is the first route in the stack, and the `details` route is pushed on top of the `index` route when navigated.
You can use the **app/\_layout.tsx** file to define your app's `Stack` navigator with these two routes:
```tsx app/_layout.tsx
import { Stack } from 'expo-router';
export default function Layout() {
  return <Stack />;
}
```
## Screen options and header configuration
Starting in SDK 55, you can configure screen options and the header using either the options-based API or the new composition components API. Both APIs can be used interchangeably in your project.
### Statically configure route options
You can use the `<Stack.Screen name={routeName} />` component in the layout component route to statically configure a route's options.
```tsx app/_layout.tsx|collapseHeight=440
import { Stack } from 'expo-router';
export default function Layout() {
  return (
    <Stack
      screenOptions={{
        headerStyle: {
          backgroundColor: '#f4511e',
        },
        headerTintColor: '#fff',
        headerTitleStyle: {
          fontWeight: 'bold',
        },
      }}>
      {/* Optionally configure static options outside the route.*/}
      <Stack.Screen name="home" options={{}} />
    </Stack>
  );
}
```
### Configure header bar
You can configure the header bar for all routes in a `Stack` navigator by using the `screenOptions` prop. This is useful for setting a common header style across all routes.
```tsx app/_layout.tsx
import { Stack } from 'expo-router';
export default function Layout() {
  return (
    <Stack
      screenOptions={{
        headerStyle: {
          backgroundColor: '#f4511e',
        },
        headerTintColor: '#fff',
        headerTitleStyle: {
          fontWeight: 'bold',
        },
      }}
    />
  );
}
```
### Set screen options dynamically
To configure a route's options dynamically, you can use either the composition components or the options-based API.
For Options API: 
```tsx app/details.tsx
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
import { View, Text, StyleSheet } from 'react-native';
export default function Details() {
  const router = useRouter();
  const params = useLocalSearchParams();
  return (
    <View style={styles.container}>
      <Stack.Screen
        options={{
          title: params.name,
          headerStyle: { backgroundColor: 'lightblue' },
        }}
      />
      <Text
        onPress={() => {
          router.setParams({ name: 'Updated' });
        }}>
        Update the title
      </Text>
    </View>
  );
}
const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },
});
```
For Composition components: 
> **important** Screen composition API is in alpha and available in SDK 55 and later.
```tsx app/details.tsx
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
import { View, Text, StyleSheet } from 'react-native';
export default function Details() {
  const router = useRouter();
  const params = useLocalSearchParams();
  return (
    <View style={styles.container}>
      <Stack.Screen.Title>{params.name}</Stack.Screen.Title>
      <Stack.Header style={{ backgroundColor: 'lightblue' }} />
      <Text
        onPress={() => {
          router.setParams({ name: 'Updated' });
        }}>
        Update the title
      </Text>
    </View>
  );
}
const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },
});
```
### Available header options
The `Stack` navigator supports comprehensive header configuration options. Below are all the header-related options available:
<ReactNavigationOptions category="header" />
For additional details and navigator-specific examples, see [React Navigation's Native Stack Navigator documentation](https://reactnavigation.org/docs/native-stack-navigator).
### Header buttons
You can add buttons to the header by using the `headerLeft` and `headerRight` options or `<Stack.Toolbar>` component. These options accept a React component that renders in the header.
For Options API: 
```tsx app/index.tsx
import { Stack } from 'expo-router';
import { Button, Text, Image, StyleSheet } from 'react-native';
import { useState } from 'react';
function LogoTitle() {
  return (
    <Image style={styles.image} source={{ uri: 'https://reactnative.dev/img/tiny_logo.png' }} />
  );
}
export default function Home() {
  const [count, setCount] = useState(0);
  return (
    <>
      <Stack.Screen
        options={{
          headerTitle: props => <LogoTitle {...props} />,
          headerRight: () => <Button onPress={() => setCount(c => c + 1)} title="Update count" />,
        }}
      />
      <Text>Count: {count}</Text>
    </>
  );
}
const styles = StyleSheet.create({
  image: {
    width: 50,
    height: 50,
  },
});
```
For Composition components: 
> **important** Screen composition API is in alpha and available in SDK 55 and later.
```tsx app/index.tsx
import { Stack } from 'expo-router';
import { Button, Text, Image, StyleSheet } from 'react-native';
import { useState } from 'react';
function LogoTitle() {
  return (
    <Image style={styles.image} source={{ uri: 'https://reactnative.dev/img/tiny_logo.png' }} />
  );
}
export default function Home() {
  const [count, setCount] = useState(0);
  return (
    <>
      <Stack.Screen.Title asChild>
        <LogoTitle />
      </Stack.Screen.Title>
      <Stack.Toolbar placement="right" asChild>
        <Button onPress={() => setCount(c => c + 1)} title="Update count" />
      </Stack.Toolbar>
      <Text>Count: {count}</Text>
    </>
  );
}
const styles = StyleSheet.create({
  image: {
    width: 50,
    height: 50,
  },
});
```
### Other screen options
For a complete list of all available other screen options including animations, gestures, and other configurations:
<ReactNavigationOptions excludeCategories={['header']} />
For additional details and navigator-specific examples, see [React Navigation's Native Stack Navigator documentation](https://reactnavigation.org/docs/native-stack-navigator).
## Custom push behavior
By default, the `Stack` navigator removes duplicate screens when pushing a route that is already in the stack. For example, if you push the same screen twice, the second push will be ignored. You can change this push behavior by providing a custom `getId()` function to the `<Stack.Screen>`.
For example, the `index` route in the following layout structure shows a list of different user profiles in the app. Let's make the `[details]` route a [dynamic route](/router/basics/notation/#square-brackets) so that the app user can navigate to see a profile's details.
```
âââ app/
    âââ _layout.tsx
    âââ index.tsx
    âââ [details].tsx  # matches dynamic paths like '/details1'
```
The `Stack` navigator will push a new screen every time the app user navigates to a different profile but will fail. If you provide a `getId()` function that returns a new ID every time, the `Stack` will push a new screen every time the app user navigates to a profile.
You can use the `<Stack.Screen name="[profile]" getId={}>` component in the layout component route to modify the push behavior:
```tsx app/_layout.tsx
import { Stack } from 'expo-router';
export default function Layout() {
  return (
    <Stack>
      <Stack.Screen
        name="[profile]"
        getId={
          ({ params }) => String(Date.now())
        }
      />
    </Stack>
  );
}
```
## Removing stack screens
There are different actions you can use to dismiss and remove one or many routes from a stack.
### `dismiss` action
Dismisses the last screen in the closest stack. If the current screen is the only route in the stack, it will dismiss the entire stack.
You can optionally pass a positive number to dismiss up to that specified number of screens.
Dismiss is different from `back` as it targets the closest stack and not the current navigator. If you have nested navigators, calling `dismiss` will take you back multiple screens.
```tsx app/settings.tsx
import { Button, View } from 'react-native';
import { useRouter } from 'expo-router';
export default function Settings() {
  const router = useRouter();
  const handleDismiss = (count: number) => {
    router.dismiss(count)
  };
  return (
    <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
      <Button title="Go to first screen" onPress={() => handleDismiss(3)} />
    </View>
  );
}
```
### `dismissTo` action
> `dismissTo` was added in Expo Router `4.0.8`. It operates similarly to the `navigation` function in Expo Router v3.
Dismisses screens in the current `<Stack />` until the specified `Href` is reached. If the `Href` is absent in the history, a `push` action will be performed instead.
For example, consider the history of `/one`, `/two`, `/three` routes, where `/three` is the current route. The action `router.dismissTo('/one')` will cause the history to go back twice, while `router.dismissTo('/four')` will `push` the history forward to the `/four` route.
```tsx app/settings.tsx
import { Button, View, Text } from 'react-native';
import { useRouter } from 'expo-router';
export default function Settings() {
  const router = useRouter();
  const handleDismissAll = () => {
    router.dismissTo('/')
  };
  return (
    <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
      <Button title="Go to first screen" onPress={handleDismissAll} />
    </View>
  );
}
```
### `dismissAll` action
To return to the first screen in the closest stack. This is similar to [`popToTop`](https://reactnavigation.org/docs/stack-actions/#poptotop) stack action.
For example, the `home` route is the first screen, and the `settings` is the last. To go from `settings` to `home` route you'll have to go back to `details`. However, using the `dismissAll` action, you can go from `settings` to `home` and dismiss any screen in between.
```tsx app/settings.tsx
import { Button, View, Text } from 'react-native';
import { useRouter } from 'expo-router';
export default function Settings() {
  const router = useRouter();
  const handleDismissAll = () => {
    router.dismissAll()
  };
  return (
    <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
      <Button title="Go to first screen" onPress={handleDismissAll} />
    </View>
  );
}
```
### `canDismiss` action
To check if it is possible to dismiss the current screen. Returns `true` if the router is within a stack with more than one screen in the stack's history.
```tsx app/settings.tsx|collapseHeight=410
import { Button, View } from 'react-native';
import { useRouter } from 'expo-router';
export default function Settings() {
  const router = useRouter();
  const handleDismiss = (count: number) => {
    if (router.canDismiss()) {
      router.dismiss(count)
    }
  };
  return (
    <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
      <Button title="Maybe dismiss" onPress={() => handleDismiss()} />
    </View>
  );
}
```
## Relation with Native Stack Navigator
The `Stack` navigator in Expo Router wraps the [Native Stack Navigator](https://reactnavigation.org/docs/native-stack-navigator) from React Navigation. Options available in the Native Stack Navigator are all available in the `Stack` navigator in Expo Router.
### JavaScript stack with @react-navigation/stack
You can also use the JavaScript-powered `@react-navigation/stack` library to create a custom layout component by wrapping this library with the `withLayoutContext`.
In the following example, `JsStack` component is defined using `@react-navigation/stack` library:
```tsx layouts/js-stack.tsx
import { ParamListBase, StackNavigationState } from '@react-navigation/native';
import {
  createStackNavigator,
  StackNavigationEventMap,
  StackNavigationOptions,
} from '@react-navigation/stack';
import { withLayoutContext } from 'expo-router';
const { Navigator } = createStackNavigator();
export const JsStack = withLayoutContext<
  StackNavigationOptions,
  typeof Navigator,
  StackNavigationState<ParamListBase>,
  StackNavigationEventMap
>(Navigator);
```
After defining the `JsStack` component, you can use it in your app:
```tsx app/_layout.tsx
import { JsStack } from '../layouts/js-stack';
export default function Layout() {
  return (
    <JsStack
      screenOptions={
        {
        }
      }
    />
  );
}
```
For more information on available options, see [`@react-navigation/stack` documentation](https://reactnavigation.org/docs/stack-navigator).
## iOS 26 Liquid Glass headers
Starting from iOS 26, navigation headers adopt the system's "Liquid Glass" effect by default. It cannot be disabled per screen, so you need to opt out using a global configuration.
### Method 1: Use `UIDesignRequiresCompatibility`
> **Note**: Not supported in Expo Go.This method is a temporary workaround. From iOS 27, this option will be removed by Apple and you cannot opt out of the Liquid Glass effect.
Create a [development build](/develop/development-builds/create-a-build/#prerequisites) and set the [`UIDesignRequiresCompatibility`](https://developer.apple.com/documentation/BundleResources/Information-Property-List/UIDesignRequiresCompatibility) property to `true` in [app config](/workflow/configuration/):
```json app.json
{
  "ios": {
    "infoPlist": {
      "UIDesignRequiresCompatibility": true
    }
  }
}
```
### Method 2: Use JavaScript-based navigation stack
Switch from native navigation library ([`@react-navigation/native`](https://reactnavigation.org/docs/native-stack-navigator/)) to a JavaScript-based stack navigator library such as [`@react-navigation/stack`](https://reactnavigation.org/docs/stack-navigator/), which gives you full control over the header UI but at the cost of performance benefits of using the highly optimized iOS navigation views/controllers.
For more information, see [JavaScript stack with `@react-navigation/stack`](#javascript-stack-with-react-navigationstack).
## Common problems
Note: Large title does not collapse when scrolling
---
When using `headerLargeTitle: true` (or `<Stack.Screen.Title large>`) with a `ScrollView` or `FlatList`, the large title may not collapse on scroll. This happens when the scrollable view is not the direct first child of the screen component.
To fix this, ensure `ScrollView` or `FlatList` is the first child rendered by your screen component. If you need a wrapper, set `collapsable={false}` on it:
```tsx app/index.tsx
import { Stack } from 'expo-router';
import { ScrollView, View, Text } from 'react-native';
export default function Home() {
  return (
    <ScrollView>
      <Stack.Screen.Title large>Home</Stack.Screen.Title>
      <Text>Content here</Text>
    </ScrollView>
  );
}
```
If you need to wrap the `ScrollView`, set `collapsable={false}` on the wrapper:
```tsx app/index.tsx
import { Stack } from 'expo-router';
import { ScrollView, View, Text } from 'react-native';
export default function Home() {
  return (
    <View collapsable={false}>
      <ScrollView>
        <Stack.Screen.Title large>Home</Stack.Screen.Title>
        <Text>Content here</Text>
      </ScrollView>
    </View>
  );
}
```
---
Note: White background flashes when navigating between screens
---
A white flash between screen transitions usually means the navigation stack is using a light background while your app uses a dark theme.
To fix this, wrap your root layout with React Navigation's `ThemeProvider` and pass the appropriate theme:
```tsx app/_layout.tsx
import { ThemeProvider, DarkTheme, DefaultTheme } from '@react-navigation/native';
import { Stack } from 'expo-router';
import { useColorScheme } from 'react-native';
export default function RootLayout() {
  const colorScheme = useColorScheme();
  return (
    <ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
      <Stack />
    </ThemeProvider>
  );
}
```
For apps that are always dark-themed:
```tsx app/_layout.tsx
import { ThemeProvider, DarkTheme } from '@react-navigation/native';
import { Stack } from 'expo-router';
export default function RootLayout() {
  return (
    <ThemeProvider value={DarkTheme}>
      <Stack />
    </ThemeProvider>
  );
}
```
---


## JavaScript tabs

Learn how to use the JavaScript tabs layout (React Navigation bottom tabs) in Expo Router.

<VideoBoxLink
  videoId="BElPB4Ai3j0"
  title="Using a JavaScript Tab Navigator with Expo Router"
  description="Configure the tab icons, nest navigators, and manage navigation history."
  className="mb-6"
/>
Tabs are a common way to navigate between different sections of an app. Expo Router provides a tabs layout to help you create a tab bar at the bottom of your app. The fastest way to get started is to use a template. See the [quick start installation](/router/installation/#quick-start) to get started.
## Multiple tab layouts
Expo Router offers three types of tab navigators:
- **JavaScript tabs**: It is implemented with React Navigation's bottom tabs and offers familiar API if you have already used React Navigation.
- **Native tabs**: It uses a platform's native tab bar and offers native look and feel.
- **Custom tabs**: It provides headless tab components from `expo-router/ui` to build a fully custom tab layout to achieve complex UI patterns.
This guide covers the **JavaScript tabs** layout. For other tab layouts see:
## Get started with JavaScript tabs
You can use file-based routing to create a tabs layout. Here's an example file structure:
```
âââ app/
    âââ _layout.tsx
    âââ (tabs)/
        âââ _layout.tsx
        âââ index.tsx
        âââ settings.tsx
```
This file structure produces a layout with a tab bar at the bottom of the screen. The tab bar will have two tabs: **Home** and **Settings**:
You can use the **app/\_layout.tsx** file to define your app's root layout:
```tsx app/_layout.tsx
import { Stack } from 'expo-router';
export default function Layout() {
  return (
    <Stack>
      <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
    </Stack>
  );
}
```
The **(tabs)** directory is a special directory name that tells Expo Router to use the `Tabs` layout.
From the file structure, the **(tabs)** directory has three files. The first is **(tabs)/\_layout.tsx**. This file is the main layout file for the tab bar and each tab. Inside it, you can control how the tab bar and each tab button look and behave.
```tsx app/(tabs)/_layout.tsx
import FontAwesome from '@expo/vector-icons/FontAwesome';
import { Tabs } from 'expo-router';
export default function TabLayout() {
  return (
    <Tabs screenOptions={{ tabBarActiveTintColor: 'blue' }}>
      <Tabs.Screen
        name="index"
        options={{
          title: 'Home',
          tabBarIcon: ({ color }) => <FontAwesome size={28} name="home" color={color} />,
        }}
      />
      <Tabs.Screen
        name="settings"
        options={{
          title: 'Settings',
          tabBarIcon: ({ color }) => <FontAwesome size={28} name="cog" color={color} />,
        }}
      />
    </Tabs>
  );
}
```
Finally, you have the two tab files that make up the content of the tabs: **app/(tabs)/index.tsx** and **app/(tabs)/settings.tsx**.
```tsx app/(tabs)/index.tsx & app/(tabs)/settings.tsx
import { View, Text, StyleSheet } from 'react-native';
export default function Tab() {
  return (
    <View style={styles.container}>
      <Text>Tab [Home|Settings]</Text>
    </View>
  );
}
const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
});
```
The tab file named **index.tsx** is the default tab when the app loads. The second tab file **settings.tsx** shows how you can add more tabs to the tab bar.
## Tab bar options
The JavaScript tabs in Expo Router extend the [Bottom Tabs Navigator](https://reactnavigation.org/docs/bottom-tab-navigator) from React Navigation. The specific APIs available depend on your versions. For example, Expo Router v6 extends Bottom Tabs Navigator v7. Check your versions to ensure compatibility, then you can use the same configuration props to customize the bottom tab bar and individual tabs. For example:
```tsx app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
export default function TabLayout() {
  return (
    <Tabs
      screenOptions={
        {
          // Here to apply for all tabs
        }
      }>
      <Tabs.Screen
        name="index"
        options={
          {
            // Or here to apply for one tab
          }
        }
      />
    </Tabs>
  );
}
```
The supported tab bar options are listed below:
<ReactNavigationOptions category="tabBar" />
For additional details and navigator-specific examples, see [React Navigation's Bottom Tabs Navigator documentation](https://reactnavigation.org/docs/bottom-tab-navigator/#options).
## Advanced
### Hiding a tab
Sometimes you want a route to exist but not show up in the tab bar. You can pass `href: null` to disable the button:
```tsx app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
export default function TabLayout() {
  return (
    <Tabs>
      <Tabs.Screen
        name="index"
        options={{
          href: null,
        }}
      />
    </Tabs>
  );
}
```
### Dynamic routes
You can use a dynamic route in a tab bar. For example, you have a `[user]` tab that shows a user's profile. You can use the `href` option to link to a specific user's profile.
```tsx app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
export default function TabLayout() {
  return (
    <Tabs>
      <Tabs.Screen
        // Name of the dynamic route.
        name="[user]"
        options={{
          // Ensure the tab always links to the same href.
          href: '/evanbacon',
          // OR you can use the href object.
          href: {
            pathname: '/[user]',
            params: {
              user: 'evanbacon',
            },
          },
        }}
      />
    </Tabs>
  );
}
```
> **Note**: When adding a dynamic route in your tab layout, ensure that the dynamic route defined is unique. You cannot have two screens for the same dynamic route. For example, you cannot have two `[user]` tabs. If you need to have multiple dynamic routes, create a custom navigator.


## Native tabs

Learn how to use the native tabs layout in Expo Router.

<VideoBoxLink
  videoId="QqNZXdGFl44"
  title="Liquid Glass Tabs with Expo Router"
  description="Learn how to use native tabs to create liquid glass tabs on iOS with Expo Router."
  className="mb-6"
/>
> **important** Native tabs is in alpha and is available in SDK 54 and later. Its API is subject to change.
Tabs are a common way to navigate between different sections of an app. In Expo Router, you can use different tab layouts, depending on your needs. This guide covers the native tabs. Unlike the [other tabs layout](/router/advanced/tabs/#multiple-tab-layouts), native tabs use the native system tab bar.
For other tab layouts see:
## Get started
You can use file-based routing to create a tabs layout. Here's an example file structure:
```
âââ app/
    âââ _layout.tsx
    âââ index.tsx
    âââ settings.tsx
```
The above file structure produces a layout with a tab bar at the bottom of the screen. The tab bar will have two tabs: **Home** and **Settings**.
You can use the **app/\_layout.tsx** file to define your app's root layout using tabs. This file is the main layout file for the tab bar and each tab. Inside it, you can control how the tab bar and each tab item look and behave.
For SDK 55 and later: 
```tsx app/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs';
export default function TabLayout() {
  return (
    <NativeTabs>
      <NativeTabs.Trigger name="index">
        <NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
        <NativeTabs.Trigger.Icon sf="house.fill" md="home" />
      </NativeTabs.Trigger>
      <NativeTabs.Trigger name="settings">
        <NativeTabs.Trigger.Icon sf="gear" md="settings" />
        <NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
      </NativeTabs.Trigger>
    </NativeTabs>
  );
}
```
For SDK 54: 
```tsx app/_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" drawable="custom_android_drawable" />
      </NativeTabs.Trigger>
      <NativeTabs.Trigger name="settings">
        <Icon sf="gear" drawable="custom_settings_drawable" />
        <Label>Settings</Label>
      </NativeTabs.Trigger>
    </NativeTabs>
  );
}
```
Finally, you have the two tab files that make up the content of the tabs: **app/index.tsx** and **app/settings.tsx**.
```tsx app/index.tsx and app/settings.tsx
import { View, Text, StyleSheet } from 'react-native';
export default function Tab() {
  return (
    <View style={styles.container}>
      <Text>Tab [Home|Settings]</Text>
    </View>
  );
}
const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
});
```
The tab file named **index.tsx** is the default tab when the app loads. The second tab file **settings.tsx** shows how you can add more tabs to the tab bar.
> **info** In contrast to the Stack navigator, tabs are not automatically added to the tab bar. You need to explicitly add them in your layout file using the `NativeTabs.Trigger`.
## Customizing tab bar items
When you want to customize the tab bar item, we recommend using the components API designed for this purpose. Currently, you can customize:
- **Icon**: The icon displayed in the tab bar item.
- **Label**: The label displayed in the tab bar item.
- **Badge**: The badge displayed in the tab bar item.
### Icon
> **info** `NativeTabs.Trigger.Icon` is available in SDK 55 and later. For SDK 54, use `Icon` imported from `expo-router/unstable-native-tabs`.
You can use the `Icon` component to customize the icon displayed in the tab bar item. The `Icon` component accepts a `md` prop for Android material symbols, a `sf` prop for Apple's SF Symbols icons, or a `src` prop for custom images.
Alternatively, you can pass `{default: ..., selected: ...}` to either the `sf` or `src` prop to specify different icons for the default and selected states (currently not supported on Android).
For SDK 55 and later: 
```tsx app/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs';
export default function TabLayout() {
  return (
    <NativeTabs>
      <NativeTabs.Trigger name="index">
        <NativeTabs.Trigger.Icon sf={{ default: 'house', selected: 'house.fill' }} md="home" />
      </NativeTabs.Trigger>
      <NativeTabs.Trigger name="settings">
        <NativeTabs.Trigger.Icon src={require('../../../assets/setting_icon.png')} />
      </NativeTabs.Trigger>
    </NativeTabs>
  );
}
```
For SDK 54: 
```tsx app/_layout.tsx
import { NativeTabs, Icon } from 'expo-router/unstable-native-tabs';
export default function TabLayout() {
  return (
    <NativeTabs>
      <NativeTabs.Trigger name="index">
        <Icon sf={{ default: 'house', selected: 'house.fill' }} drawable="custom_home_drawable" />
      </NativeTabs.Trigger>
      <NativeTabs.Trigger name="settings">
        <Icon src={require('../../../assets/setting_icon.png')} />
      </NativeTabs.Trigger>
    </NativeTabs>
  );
}
```
Liquid glass on iOS automatically changes colors based on if the background color is light or dark. There is no callback for this, so you need to use a `PlatformColor` or `DynamicColorIOS` to set the color of the icon.
For SDK 55 and later: 
```tsx app/_layout.tsx
import { DynamicColorIOS } from 'react-native';
import { NativeTabs } from 'expo-router/unstable-native-tabs';
export default function TabLayout() {
  return (
    <NativeTabs
      labelStyle={{
        // For the text color
        color: DynamicColorIOS({
          dark: 'white',
          light: 'black',
        }),
      }}
      // For the selected icon color
      tintColor={DynamicColorIOS({
        dark: 'white',
        light: 'black',
      })}>
      <NativeTabs.Trigger name="index">
        <NativeTabs.Trigger.Icon sf={{ default: 'house', selected: 'house.fill' }} md="home" />
      </NativeTabs.Trigger>
      <NativeTabs.Trigger name="settings">
        <NativeTabs.Trigger.Icon
          src={{
            default: require('../assets/setting_icon.png'),
            selected: require('../assets/selected_setting_icon.png'),
          }}
        />
      </NativeTabs.Trigger>
    </NativeTabs>
  );
}
```
For SDK 54: 
```tsx app/_layout.tsx
import { DynamicColorIOS } from 'react-native';
import { NativeTabs, Icon } from 'expo-router/unstable-native-tabs';
export default function TabLayout() {
  return (
    <NativeTabs
      labelStyle={{
        // For the text color
        color: DynamicColorIOS({
          dark: 'white',
          light: 'black',
        }),
      }}
      // For the selected icon color
      tintColor={DynamicColorIOS({
        dark: 'white',
        light: 'black',
      })}>
      <NativeTabs.Trigger name="index">
        <Icon sf={{ default: 'house', selected: 'house.fill' }} drawable="custom_home_drawable" />
      </NativeTabs.Trigger>
      <NativeTabs.Trigger name="settings">
        <Icon
          src={{
            default: require('../assets/setting_icon.png'),
            selected: require('../assets/selected_setting_icon.png'),
          }}
        />
      </NativeTabs.Trigger>
    </NativeTabs>
  );
}
```
#### Icon rendering mode
> **important** Icon rendering mode is available in SDK 55 and later.
When using the `src` prop for custom images on iOS, you can control how the icon is rendered with the `renderingMode` prop:
- **`template` (default)**: The icon is rendered as a template image, allowing iOS to apply the tint color. This is ideal for single-color icons that should match your app's color scheme.
- **`original`**: The icon is rendered with its original colors preserved. This is useful for icons with gradients or multiple colors.
For SDK 55 and later: 
```tsx app/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs';
export default function TabLayout() {
  return (
    <NativeTabs>
      {/* Icon with original colors preserved (e.g., for gradient or multi-color icons) */}
      <NativeTabs.Trigger name="colorful">
        <NativeTabs.Trigger.Icon
          src={require('../../../assets/colorful_icon.png')}
          renderingMode="original"
        />
      </NativeTabs.Trigger>
      {/* Icon rendered as a template (default behavior) */}
      <NativeTabs.Trigger name="simple">
        <NativeTabs.Trigger.Icon
          src={require('../../../assets/simple_icon.png')}
          renderingMode="template"
        />
      </NativeTabs.Trigger>
    </NativeTabs>
  );
}
```
For SDK 54: 
```tsx app/_layout.tsx
import { NativeTabs, Icon } from 'expo-router/unstable-native-tabs';
export default function TabLayout() {
  return (
    <NativeTabs>
      {/* Icon with original colors preserved (e.g., for gradient or multi-color icons) */}
      <NativeTabs.Trigger name="colorful">
        <Icon src={require('../../../assets/colorful_icon.png')} renderingMode="original" />
      </NativeTabs.Trigger>
      {/* Icon rendered as a template (default behavior) */}
      <NativeTabs.Trigger name="simple">
        <Icon src={require('../../../assets/simple_icon.png')} renderingMode="template" />
      </NativeTabs.Trigger>
    </NativeTabs>
  );
}
```
> **info** The `renderingMode` prop only affects iOS. On Android, all image icons are rendered with their original colors.
### Label
You can use the `Label` component to customize the label displayed in the tab bar item. The `Label` component accepts a string label passed as a child. If no label is provided, the tab bar item will use the route name as the label.
If you don't want to display a label, you can use the `hidden` prop to hide the label.
For SDK 55 and later: 
```tsx app/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs';
export default function TabLayout() {
  return (
    <NativeTabs>
      <NativeTabs.Trigger name="index">
        <NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
      </NativeTabs.Trigger>
      <NativeTabs.Trigger name="settings">
        <NativeTabs.Trigger.Label hidden />
      </NativeTabs.Trigger>
    </NativeTabs>
  );
}
```
For SDK 54: 
```tsx app/_layout.tsx
import { NativeTabs, Label } from 'expo-router/unstable-native-tabs';
export default function TabLayout() {
  return (
    <NativeTabs>
      <NativeTabs.Trigger name="index">
        <Label>Home</Label>
      </NativeTabs.Trigger>
      <NativeTabs.Trigger name="settings">
        <Label hidden />
      </NativeTabs.Trigger>
    </NativeTabs>
  );
}
```
### Badge
You can use the `Badge` component to customize the badge displayed for the tab bar item. The badge is an additional mark on top of the tab and useful for showing notification or unread message counts.
For SDK 55 and later: 
```tsx app/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs';
export default function TabLayout() {
  return (
    <NativeTabs>
      <NativeTabs.Trigger name="messages">
        <NativeTabs.Trigger.Badge>9+</NativeTabs.Trigger.Badge>
      </NativeTabs.Trigger>
      <NativeTabs.Trigger name="settings">
        <NativeTabs.Trigger.Badge />
      </NativeTabs.Trigger>
    </NativeTabs>
  );
}
```
For SDK 54: 
```tsx app/_layout.tsx
import { NativeTabs, Badge } from 'expo-router/unstable-native-tabs';
export default function TabLayout() {
  return (
    <NativeTabs>
      <NativeTabs.Trigger name="messages">
        <Badge>9+</Badge>
      </NativeTabs.Trigger>
      <NativeTabs.Trigger name="settings">
        <Badge />
      </NativeTabs.Trigger>
    </NativeTabs>
  );
}
```
## Customizing the tab bar
Since the native tab layout's appearance varies by platform, the customization options are also different. For all customization options, see the [API reference for `NativeTabs`](/versions/latest/sdk/router-native-tabs/).
## Advanced
### Hiding the Tab bar
> **info** `hidden` property is available in SDK 55 and later.
You can hide the tab bar using `hidden` prop on the `NativeTabs` component. To hide tab bar for specific screens, you can use context API to set the `hidden` prop dynamically.
```tsx context/TabBarContext.tsx
import { createContext } from 'react';
export const TabBarContext = createContext<{
  setIsTabBarHidden: (hidden: boolean) => void;
}>({
  setIsTabBarHidden: () => {},
});
```
```tsx app/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs';
import { useState } from 'react';
import { TabBarContext } from '../context/TabBarContext';
export default function TabLayout() {
  const [isTabBarHidden, setIsTabBarHidden] = useState(false);
  return (
    <TabBarContext value={{ setIsTabBarHidden }}>
      <NativeTabs hidden={isTabBarHidden}>
        <NativeTabs.Trigger name="index">
          <NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
        </NativeTabs.Trigger>
        <NativeTabs.Trigger name="settings">
          <NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
        </NativeTabs.Trigger>
      </NativeTabs>
    </TabBarContext>
  );
}
```
```tsx app/index.tsx
import { useFocusEffect } from 'expo-router';
import { use } from 'react';
import { TabBarContext } from '../context/TabBarContext';
export default function HomeScreen() {
  const { setIsTabBarHidden } = use(TabBarContext);
  useFocusEffect(() => {
    setIsTabBarHidden(true);
    return () => setIsTabBarHidden(false);
  });
  return (
    // Screen content
  );
}
```
### Hiding a tab conditionally
> **warning** Dynamically hiding tabs will remount the navigator and the state will be reset. Change the visibility of the tabs only before the navigator is mounted or when it is not visible to the user.
If you want to hide a tab based on a condition, you can either remove the trigger or pass the `hidden` prop to the `NativeTabs.Trigger` component.
```tsx app/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs';
export default function TabLayout() {
  const shouldHideMessagesTab = true; // Replace with your condition
  return (
    <NativeTabs>
      <NativeTabs.Trigger name="messages" hidden={shouldHideMessagesTab} />
    </NativeTabs>
  );
}
```
> **info** **Note**: Marking a tab as `hidden` means it cannot be navigated to in any way.
### Dismiss behavior
> **info** Dismiss behavior is available on Android in SDK 55 and later.
By default, tapping a tab that is already active closes all screens in that tab's stack and returns to the root screen. You can disable this by setting the `disablePopToTop` prop on the `NativeTabs.Trigger` component.
For SDK 55 and later: 
```tsx app/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs';
export default function TabLayout() {
  return (
    <NativeTabs>
      <NativeTabs.Trigger name="index" disablePopToTop>
        <NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
      </NativeTabs.Trigger>
      <NativeTabs.Trigger name="settings">
        <NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
      </NativeTabs.Trigger>
    </NativeTabs>
  );
}
```
For SDK 54: 
```tsx app/_layout.tsx
import { NativeTabs, Label } from 'expo-router/unstable-native-tabs';
export default function TabLayout() {
  return (
    <NativeTabs>
      <NativeTabs.Trigger name="index" disablePopToTop>
        <Label>Home</Label>
      </NativeTabs.Trigger>
      <NativeTabs.Trigger name="settings">
        <Label>Settings</Label>
      </NativeTabs.Trigger>
    </NativeTabs>
  );
}
```
### Scroll to top
> **info** Scroll to top is available on Android in SDK 55 and later.
By default, tapping a tab that is already active and showing its root screen scrolls the content back to the top. You can disable this by setting the `disableScrollToTop` prop on the `NativeTabs.Trigger` component.
For SDK 55 and later: 
```tsx app/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs';
export default function TabLayout() {
  return (
    <NativeTabs>
      <NativeTabs.Trigger name="index" disableScrollToTop>
        <NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
      </NativeTabs.Trigger>
      <NativeTabs.Trigger name="settings">
        <NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
      </NativeTabs.Trigger>
    </NativeTabs>
  );
}
```
For SDK 54: 
```tsx app/_layout.tsx
import { NativeTabs, Label } from 'expo-router/unstable-native-tabs';
export default function TabLayout() {
  return (
    <NativeTabs>
      <NativeTabs.Trigger name="index" disableScrollToTop>
        <Label>Home</Label>
      </NativeTabs.Trigger>
      <NativeTabs.Trigger name="settings">
        <Label>Settings</Label>
      </NativeTabs.Trigger>
    </NativeTabs>
  );
}
```
### iOS 26 features
> **info** To use features described in this section, compile your app with Xcode 26 or higher.
#### Separate search tab
To add a separate search tab, assign the `role` with its value set to `search` to the native tab you want to display separately.
For SDK 55 and later: 
```tsx app/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs';
export default function TabLayout() {
  return (
    <NativeTabs>
      <NativeTabs.Trigger name="index">
        <NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
      </NativeTabs.Trigger>
      <NativeTabs.Trigger name="search" role="search">
        <NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>
      </NativeTabs.Trigger>
    </NativeTabs>
  );
}
```
For SDK 54: 
```tsx app/_layout.tsx
import { NativeTabs, Label } from 'expo-router/unstable-native-tabs';
export default function TabLayout() {
  return (
    <NativeTabs>
      <NativeTabs.Trigger name="index">
        <Label>Home</Label>
      </NativeTabs.Trigger>
      <NativeTabs.Trigger name="search" role="search">
        <Label>Search</Label>
      </NativeTabs.Trigger>
    </NativeTabs>
  );
}
```
#### Tabbar search input
To add a search field to the tab bar, wrap the screen in a Stack navigator and configure `headerSearchBarOptions`.
```
âââ app/
    âââ _layout.tsx
    âââ index.tsx
    âââ search/
        âââ _layout.tsx
        âââ index.tsx
```
```tsx app/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs';
export default function TabLayout() {
  return (
    <NativeTabs>
      <NativeTabs.Trigger name="index">
        <NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
      </NativeTabs.Trigger>
      <NativeTabs.Trigger name="search" role="search">
        <NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>
      </NativeTabs.Trigger>
    </NativeTabs>
  );
}
```
```tsx app/search/_layout.tsx
import { Stack } from 'expo-router';
export default function SearchLayout() {
  return <Stack />;
}
```
```tsx app/search/index.tsx
import { ScrollView } from 'react-native';
import { Stack } from 'expo-router';
export default function SearchIndex() {
  return (
    <>
      <Stack.Screen.Title>Search</Stack.Screen.Title>
      <Stack.SearchBar placement="automatic" placeholder="Search" onChangeText={() => {}} />
      <ScrollView>{/* Screen content */}</ScrollView>
    </>
  );
}
```
#### Tab bar minimize behavior
To implement the minimized behavior on the tab bar, you can use
[`minimizeBehavior`](/versions/latest/sdk/router-native-tabs/#minimizebehavior) prop on
`NativeTabs`. In the example below, the tab bar is minimized when scrolling down.
For SDK 55 and later: 
```tsx app/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs';
export default function TabLayout() {
  return (
    <NativeTabs minimizeBehavior="onScrollDown">
      <NativeTabs.Trigger name="index">
        <NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
      </NativeTabs.Trigger>
      <NativeTabs.Trigger name="tab-1">
        <NativeTabs.Trigger.Label>Tab 1</NativeTabs.Trigger.Label>
      </NativeTabs.Trigger>
    </NativeTabs>
  );
}
```
For SDK 54: 
```tsx app/_layout.tsx
import { NativeTabs, Label } from 'expo-router/unstable-native-tabs';
export default function TabLayout() {
  return (
    <NativeTabs minimizeBehavior="onScrollDown">
      <NativeTabs.Trigger name="index">
        <Label>Home</Label>
      </NativeTabs.Trigger>
      <NativeTabs.Trigger name="tab-1">
        <Label>Tab 1</Label>
      </NativeTabs.Trigger>
    </NativeTabs>
  );
}
```
#### Bottom accessory
> **info** This feature is available in SDK 55 and later.
A bottom accessory is a floating view that appears above the tab bar, useful for displaying persistent controls like a mini music player. See Apple's [`UITabBarController` bottomAccessory documentation](https://developer.apple.com/documentation/uikit/uitabbarcontroller/bottomaccessory) for more details.
The bottom accessory can appear in two placements: `'regular'` (standard position above the tab bar) or `'inline'` (compact mode, inline with the tab bar). Use the `usePlacement` hook to adapt your UI based on the current placement.
> **warning** You must store state outside the accessory component using props, context, or external state management. Two instances of the bottom accessory component are rendered simultaneously (one for each placement) and state is **not** shared between them.
The following example demonstrates a mini player with state lifted to the parent component:
```tsx app/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs';
import { useState } from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
function MiniPlayer({ isPlaying, onToggle }) {
  const placement = NativeTabs.BottomAccessory.usePlacement();
  if (placement === 'inline') {
    // Compact UI for inline placement
    return (
      <Pressable onPress={onToggle} style={styles.inlinePlayer}>
        <Text>{isPlaying ? 'â¸' : 'â¶'}</Text>
      </Pressable>
    );
  }
  // Full UI for regular placement
  return (
    <View style={styles.regularPlayer}>
      <Text>Now Playing: Song Title</Text>
      <Pressable onPress={onToggle}>
        <Text>{isPlaying ? 'Pause' : 'Play'}</Text>
      </Pressable>
    </View>
  );
}
export default function TabLayout() {
  // State must be stored outside BottomAccessory
  const [isPlaying, setIsPlaying] = useState(false);
  return (
    <NativeTabs>
      <NativeTabs.BottomAccessory>
        <MiniPlayer isPlaying={isPlaying} onToggle={() => setIsPlaying(!isPlaying)} />
      </NativeTabs.BottomAccessory>
      <NativeTabs.Trigger name="index">
        <NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
      </NativeTabs.Trigger>
      <NativeTabs.Trigger name="library">
        <NativeTabs.Trigger.Label>Library</NativeTabs.Trigger.Label>
      </NativeTabs.Trigger>
    </NativeTabs>
  );
}
const styles = StyleSheet.create({
  inlinePlayer: {
    padding: 8,
  },
  regularPlayer: {
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'space-between',
    padding: 16,
  },
});
```
### Disabling keyboard avoidance on Android
By default, when the keyboard is displayed on Android, the native tabs automatically adjust to avoid being obscured. You can disable this behavior by changing the [`android.softwareKeyboardLayoutMode`](/versions/latest/config/app/#softwarekeyboardlayoutmode) property to `pan` in your app config file:
```json app.json
{
  "expo": {
    "android": {
      "softwareKeyboardLayoutMode": "pan"
    }
  }
}
```
### Safe area handling
> **info** This feature is available in SDK 55 and later.
Native tabs automatically handle safe area insets, with platform-specific behavior:
- **Android**: Screen content is automatically wrapped in a `SafeAreaView` that applies the **bottom** inset for the tab bar. Other insets (top, left, right) must be handled manually.
- **iOS**: The first `ScrollView` nested inside a native tabs screen has [automatic content inset adjustment](https://reactnative.dev/docs/scrollview#contentinsetadjustmentbehavior-ios) enabled. This ensures content scrolls correctly behind the tab bar.
#### Disabling automatic content insets
If you need full control over safe area handling, you can disable automatic content inset adjustment using the `disableAutomaticContentInsets` prop on `NativeTabs.Trigger`:
For SDK 55 and later: 
```tsx app/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs';
export default function TabLayout() {
  return (
    <NativeTabs>
      <NativeTabs.Trigger name="index" disableAutomaticContentInsets>
        <NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
      </NativeTabs.Trigger>
    </NativeTabs>
  );
}
```
For SDK 54: 
```tsx app/_layout.tsx
import { NativeTabs, Label } from 'expo-router/unstable-native-tabs';
export default function TabLayout() {
  return (
    <NativeTabs>
      <NativeTabs.Trigger name="index" disableAutomaticContentInsets>
        <Label>Home</Label>
      </NativeTabs.Trigger>
    </NativeTabs>
  );
}
```
When `disableAutomaticContentInsets` is set to `true`, you must manage safe area insets manually. You can use `SafeAreaView` from `react-native-screens/experimental`:
```tsx app/index.tsx
import { SafeAreaView } from 'react-native-screens/experimental';
export default function HomeScreen() {
  return (
    <SafeAreaView edges={{ bottom: true }} style={{ flex: 1 }}>
      {/* Screen content */}
    </SafeAreaView>
  );
}
```
## Migrating native tabs from SDK 54 to 55
SDK 55 changes how you access tab bar item components. Instead of importing `Icon`, `Label`, and `Badge` separately, use the compound component API: `NativeTabs.Trigger.Icon`, `NativeTabs.Trigger.Label`, and `NativeTabs.Trigger.Badge`. For Android icons, the `md` prop is the new recommended way to use Material Symbols.
<DiffBlock source="/static/diffs/router/native-tabs/sdk-54-to-55-migration.diff" />
## Migrating from JavaScript tabs
Native tabs are not designed to be a drop-in replacement for [JavaScript tabs](/router/advanced/tabs/). The native tabs are constrained to the native platform behavior, whereas the JavaScript tabs can be customized more freely. If you aren't interested in the native platform behavior, you can continue using the JavaScript tabs.
### Use `Trigger` instead of `Screen`
`NativeTabs` introduces the concept of a `Trigger` for adding routes to a layout. Unlike a `Screen`, which styles routes that are added automatically, the `Trigger` system gives you better control for hiding and removing tabs from the tab bar.
### Use React components instead of props
`NativeTabs` has a React-first API that opts to use components for defining UI in favor of props objects.
<DiffBlock source="/static/diffs/router/native-tabs/trigger-icon-component.diff" />
### Use Stacks inside tabs
The JavaScript `` have a mock stack header which is not present in the native tabs. Instead, you should nest a native `<Stack />` layout inside the native tabs to support both headers and pushing screens.
## Common problems
Note: The tab bar is transparent on iOS 18 and earlier
---
On iOS 18 and earlier, the native tab bar becomes transparent when scrolling to the end of a scrollable content. This means that it will become transparent when you scroll to the end of a `ScrollView` or when you render a static `View`.
You can use the [`disableTransparentOnScrollEdge`](/versions/latest/sdk/router-native-tabs/#disabletransparentonscrolledge) prop to disable this behavior.
```tsx app/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs';
function TabLayout() {
  return (
    <NativeTabs>
      <NativeTabs.Trigger name="index" disableTransparentOnScrollEdge>
        <NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
      </NativeTabs.Trigger>
    </NativeTabs>
  );
}
```
When you are using a `ScrollView` and the tab bar is transparent from the start, ensure that the `ScrollView` is a first child of the screen component. If you wrap it with another component make sure to set `collapsable` to `false` on the wrapper component.
```tsx app/index.tsx
import { ScrollView, View } from 'react-native';
export default function HomeScreen() {
  return (
    <View collapsable={false} style={{ flex: 1 }}>
      <ScrollView>{/* Screen content */}</ScrollView>
    </View>
  );
}
```
---
Note: White background flashes when switching tabs on iOS 26
---
This happens because React Navigation's default theme uses a white background color. To fix this, wrap your app in React Navigation's `ThemeProvider` with the appropriate theme.
**For apps supporting both light and dark modes:**
```tsx app/_layout.tsx
import { ThemeProvider, DarkTheme, DefaultTheme } from '@react-navigation/native';
import { NativeTabs } from 'expo-router/unstable-native-tabs';
import { useColorScheme } from 'react-native';
export default function TabLayout() {
  const colorScheme = useColorScheme();
  return (
    <ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
      <NativeTabs>
        <NativeTabs.Trigger name="index">
          <NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
        </NativeTabs.Trigger>
        <NativeTabs.Trigger name="settings">
          <NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
        </NativeTabs.Trigger>
      </NativeTabs>
    </ThemeProvider>
  );
}
```
**For dark-mode-only apps:**
```tsx app/_layout.tsx
import { ThemeProvider, DarkTheme } from '@react-navigation/native';
import { NativeTabs } from 'expo-router/unstable-native-tabs';
export default function TabLayout() {
  return (
    <ThemeProvider value={DarkTheme}>
      <NativeTabs>{/* tabs */}</NativeTabs>
    </ThemeProvider>
  );
}
```
**Alternative for specific background colors:**
If you need a specific background color that doesn't match the default themes, you can use the [`contentStyle`](/versions/latest/sdk/router-native-tabs/#contentstyle) prop on `NativeTabs.Trigger`:
```tsx
<NativeTabs.Trigger name="index" contentStyle={{ backgroundColor: '#1a1a2e' }}>
```
---
Note: Scroll to top does not work when tapping a tab
---
Tapping an active tab should scroll the content to the top, but this may not work if the `ScrollView` is not the first child of the screen component.
Ensure that the `ScrollView` is a direct first child of the screen component. If you wrap it with another component, make sure to set `collapsable` to `false` on the wrapper component.
```tsx app/index.tsx
import { ScrollView, View } from 'react-native';
export default function HomeScreen() {
  return (
    <View collapsable={false} style={{ flex: 1 }}>
      <ScrollView>{/* Screen content */}</ScrollView>
    </View>
  );
}
```
---
## Known limitations
Note: A limit of 5 tabs on Android
---
On Android, there is a limitation of having a maximum of 5 tabs in the tab bar. This restriction comes from the platform's Material Tabs component.
---
Note: Cannot measure the tab bar height
---
The tabs move around, sometimes being on top of the screen when rendering on iPad, sometimes on the side of the screen when running on Apple Vision Pro, and so on. We're working on a layout function to provide more detailed layout info in the future.
---
Note: No support for nested native tabs
---
Native tabs cannot be nested inside other native tabs. You can still nest [JavaScript tabs](/router/advanced/tabs/) inside native tabs.
---
Note: Limited support for FlatList
---
[FlatList](https://reactnative.dev/docs/flatlist) integration with native tabs has limitations. Features like scroll-to-top and minimize-on-scroll aren't supported. Additionally, detecting scroll edges may fail, causing the tab bar to appear transparent. To fix this, use the [`disableTransparentOnScrollEdge`](/versions/latest/sdk/router-native-tabs/#disabletransparentonscrolledge) prop.
For SDK 55 and later: 
```tsx app/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs';
export default function TabLayout() {
  return (
    <NativeTabs disableTransparentOnScrollEdge>
      <NativeTabs.Trigger name="index">
        <NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
      </NativeTabs.Trigger>
    </NativeTabs>
  );
}
```
For SDK 54: 
```tsx app/_layout.tsx
import { NativeTabs, Label } from 'expo-router/unstable-native-tabs';
export default function TabLayout() {
  return (
    <NativeTabs disableTransparentOnScrollEdge>
      <NativeTabs.Trigger name="index">
        <Label>Home</Label>
      </NativeTabs.Trigger>
    </NativeTabs>
  );
}
```
---
Note: No support for dynamically adding or removing tabs
---
Dynamically adding or removing tabs at runtime is not supported. Tabs should be defined statically in your layout file and remain consistent throughout the app's lifecycle. This aligns with platform guidelines from [Apple's Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/tab-bars#Best-practices) which recommend keeping tab bar content stable to help users build a mental model of your app's navigation structure. If you dynamically add or remove tabs, the content will be remounted and the state will be lost.
---


## Drawer

Learn how to use the Drawer layout in Expo Router.

A navigation drawer is a common pattern in mobile apps, it allows users to swipe open a menu from a side of their screen to expose navigation options. This menu is also typically toggleable through a button in the app's header.
## Installation
For SDK 54 and later: 
To use [drawer navigator](https://reactnavigation.org/docs/drawer-based-navigation) you'll need to install some additional dependencies if you do not have them already. On Android and iOS, the drawer navigator requires `react-native-reanimated` and `react-native-worklets` to drive its animations. On web, this is handled by CSS animations.
```sh
$ npx expo install @react-navigation/drawer react-native-reanimated react-native-worklets
```
For SDK 53 and earlier: 
To use [drawer navigator](https://reactnavigation.org/docs/drawer-based-navigation) you'll need to install some additional dependencies if you do not have them already. On Android and iOS, the drawer navigator requires `react-native-reanimated` and `react-native-gesture-handler` to drive its animations. On web, this is handled by CSS animations.
```sh
$ npx expo install @react-navigation/drawer react-native-reanimated react-native-gesture-handler
```
## Usage
Now you can use the `Drawer` layout to create a drawer navigator.
```tsx app/_layout.tsx
import { Drawer } from 'expo-router/drawer';
export default function Layout() {
  return <Drawer />;
}
```
To edit the drawer navigation menu labels, titles and screen options specific screens are required as follows:
```tsx app/_layout.tsx
import { Drawer } from 'expo-router/drawer';
export default function Layout() {
  return (
    <Drawer>
      <Drawer.Screen
        name="index" // This is the name of the page and must match the url from root
        options={{
          drawerLabel: 'Home',
          title: 'overview',
        }}
      />
      <Drawer.Screen
        name="user/[id]" // This is the name of the page and must match the url from root
        options={{
          drawerLabel: 'User',
          title: 'overview',
        }}
      />
    </Drawer>
  );
}
```


## Authentication in Expo Router

How to implement authentication and protect routes with Expo Router.

> **info** **Note:** This guide requires SDK 53 and later. For the previous version of this guide see [Authentication (redirects)](/router/advanced/authentication-rewrites/).
With Expo Router, all routes are always defined and accessible. You can use runtime logic to redirect users away from specific screens depending on whether they are authenticated. There are two different techniques for authenticating users within routes. This guide provides an example that demonstrates the functionality of standard native apps.
## Using Protected Routes
[Protected routes](/router/advanced/protected/) allow you to prevent users from accessing certain routes using client-side navigation. If a user tries to navigate to a protected screen, or if a screen becomes protected while it is active, they will be redirected to the anchor route (usually the index screen) or the first available screen in the stack. Consider the following project structure that has a `/sign-in` route that is always accessible and a `(app)` group that requires authentication:
```
âââ app/
    âââ _layout.tsx  # Controls what is protected
    âââ sign-in.tsx  # Always accessible
    âââ (app)/
        âââ _layout.tsx  # Requires authorization
        âââ index.tsx  # Should be protected by the (app)/_layout
```
Step 1: 
To follow the above example, set up a [React Context provider](https://react.dev/reference/react/createContext) that can expose an authentication session to the entire app. You can implement your custom authentication session provider or use the one from the **Example authentication context** below.
Note: Example authentication context
---
This provider uses a mock implementation. You can replace it with your own [authentication provider](/guides/authentication/).
```tsx ctx.tsx
import { use, createContext, type PropsWithChildren } from 'react';
import { useStorageState } from './useStorageState';
const AuthContext = createContext<{
  signIn: () => void;
  signOut: () => void;
  session?: string | null;
  isLoading: boolean;
}>({
  signIn: () => null,
  signOut: () => null,
  session: null,
  isLoading: false,
});
// Use this hook to access the user info.
export function useSession() {
  const value = use(AuthContext);
  if (!value) {
    throw new Error('useSession must be wrapped in a <SessionProvider />');
  }
  return value;
}
export function SessionProvider({ children }: PropsWithChildren) {
  const [[isLoading, session], setSession] = useStorageState('session');
  return (
    <AuthContext.Provider
      value={{
        signIn: () => {
          // Perform sign-in logic here
          setSession('xxx');
        },
        signOut: () => {
          setSession(null);
        },
        session,
        isLoading,
      }}>
      {children}
    </AuthContext.Provider>
  );
}
```
The following code snippet is a basic hook that persists tokens securely on native with [`expo-secure-store`](/versions/latest/sdk/securestore) and in local storage on web.
```tsx useStorageState.ts
import  { useEffect, useCallback, useReducer } from 'react';
import * as SecureStore from 'expo-secure-store';
import { Platform } from 'react-native';
type UseStateHook<T> = [[boolean, T | null], (value: T | null) => void];
function useAsyncState<T>(
  initialValue: [boolean, T | null] = [true, null],
): UseStateHook<T> {
  return useReducer(
    (state: [boolean, T | null], action: T | null = null): [boolean, T | null] => [false, action],
    initialValue
  ) as UseStateHook<T>;
}
export async function setStorageItemAsync(key: string, value: string | null) {
  if (Platform.OS === 'web') {
    try {
      if (value === null) {
        localStorage.removeItem(key);
      } else {
        localStorage.setItem(key, value);
      }
    } catch (e) {
      console.error('Local storage is unavailable:', e);
    }
  } else {
    if (value == null) {
      await SecureStore.deleteItemAsync(key);
    } else {
      await SecureStore.setItemAsync(key, value);
    }
  }
}
export function useStorageState(key: string): UseStateHook<string> {
  // Public
  const [state, setState] = useAsyncState<string>();
  // Get
  useEffect(() => {
    if (Platform.OS === 'web') {
      try {
        if (typeof localStorage !== 'undefined') {
          setState(localStorage.getItem(key));
        }
      } catch (e) {
        console.error('Local storage is unavailable:', e);
      }
    } else {
      SecureStore.getItemAsync(key).then((value: string | null) => {
        setState(value);
      });
    }
  }, [key]);
  // Set
  const setValue = useCallback(
    (value: string | null) => {
      setState(value);
      setStorageItemAsync(key, value);
    },
    [key]
  );
  return [state, setValue];
}
```
---
Step 2: 
Create a **SplashScreenController** to manage the splash screen. Authentication loading is asynchronous, so keep the splash screen visible until authentication loads.
```tsx splash.tsx
import { SplashScreen } from 'expo-router';
import { useSession } from './ctx';
SplashScreen.preventAutoHideAsync();
export function SplashScreenController() {
  const { isLoading } = useSession();
  if (!isLoading) {
    SplashScreen.hide();
  }
  return null;
}
```
Step 3: 
Add the `SessionProvider` to your root layout. This gives your entire app access to the authentication context. Ensure the `SplashScreenController` is inside the `SessionProvider`.
```tsx app/_layout.tsx
import { Stack } from 'expo-router';
import { SessionProvider } from '../ctx';
import { SplashScreenController } from '../splash';
export default function Root() {
  // Set up the auth context and render your layout inside of it.
  return (
    <SessionProvider>
      <SplashScreenController />
      <RootNavigator />
    </SessionProvider>
  );
}
// Create a new component that can access the SessionProvider context later.
function RootNavigator() {
  return <Stack />;
}
```
Step 4: 
Create the `/sign-in` screen. This screen toggles authentication using `signIn()`. Since this screen is outside the `(app)` group, the group's layout and authentication check do not run when rendering this screen. This lets logged-out users access this screen.
```tsx app/sign-in.tsx|collapseHeight=480
import { router } from 'expo-router';
import { Text, View } from 'react-native';
import { useSession } from '../ctx';
export default function SignIn() {
  const { signIn } = useSession();
  return (
    <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
      <Text
        onPress={() => {
          signIn();
          // Navigate after signing in. You may want to tweak this to ensure sign-in is successful before navigating.
          router.replace('/');
        }}>
        Sign In
      </Text>
    </View>
  );
}
```
Step 5: 
Now modify the `RootNavigator` to protect routes based on your `SessionProvider`.
```tsx app/_layout.tsx|collapseHeight=400
// All import statements remain the same except you need to import `useSession` from your `ctx.tsx` file.
import { SessionProvider, useSession } from '../ctx';
// All of the above code remains unchanged. Update the `RootNavigator` to protect routes based on your `SessionProvider` below.
function RootNavigator() {
  const { session } = useSession();
  return (
    <Stack>
      <Stack.Protected guard={!!session}>
        <Stack.Screen name="(app)" />
      </Stack.Protected>
      <Stack.Protected guard={!session}>
        <Stack.Screen name="sign-in" />
      </Stack.Protected>
    </Stack>
  );
}
```
Step 6: 
Implement an authenticated screen that lets users sign out.
```tsx app/(app)/index.tsx|collapseHeight=480
import { Text, View } from 'react-native';
import { useSession } from '../../ctx';
export default function Index() {
  const { signOut } = useSession();
  return (
    <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
      <Text
        onPress={() => {
          // The guard in `RootNavigator` redirects back to the sign-in screen.
          signOut();
        }}>
        Sign Out
      </Text>
    </View>
  );
}
```
Step 7: 
Create the **app/(app)/\_layout.tsx**:
```tsx app/(app)/_layout.tsx
import { Stack } from 'expo-router';
export default function AppLayout() {
  // This renders the navigation stack for all authenticated app routes.
  return <Stack />;
}
```
You now have an app that will present the splash screen until the initial authentication state has loaded and will redirects to the sign-in screen if the user is not authenticated. If a user visits a deep link to any routes with the authentication check, they'll be redirected to the sign-in screen.
## Modals and per-route authentication
Another common pattern is to render a sign-in modal over the top of the app. This enables you to dismiss and partially preserve deep links when the authentication is complete. However, this pattern requires routes to be rendered in the background as these routes require handling data loading without authentication.
```
âââ app/
    âââ _layout.tsx  # Declares global session context
    âââ (app)/
        âââ _layout.tsx
        âââ sign-in.tsx  # Modal presented over the root
        âââ (root)/
            âââ _layout.tsx  # Protects child routes
            âââ index.tsx  # Requires authorization
```
```tsx app/(app)/_layout.tsx|collapseHeight=480
import { Stack } from 'expo-router';
export const unstable_settings = {
  initialRouteName: '(root)',
};
export default function AppLayout() {
  return (
    <Stack>
      <Stack.Screen name="(root)" />
      <Stack.Screen
        name="sign-in"
        options={{
          presentation: 'modal',
        }}
      />
    </Stack>
  );
}
```
## More information
For more information, read the [Protected routes documentation](/router/advanced/protected/) to learn more about patterns.
Video Tutorial: [How to use Protected Routes in Expo Router version 5 and later for smooth authentication](https://www.youtube.com/watch?v=XCTaMu0qnFY)
## Middleware
Traditionally, websites may leverage some form of server-side redirection to protect routes. Expo Router on the web currently only supports build-time static generation and has no support for custom middleware or serving. This can be added in the future to provide a more optimal web experience. In the meantime, authentication can be implemented by using client-side redirects and a loading state.


## Authentication in Expo Router using redirects

How to implement authentication and protect routes with Expo Router.

> **info** SDK 53 introduced [Protected routes](/router/advanced/protected/), a more powerful method of handling authentication. Please follow this guide if you are using SDK 52 and earlier.
<VideoBoxLink
  videoId="yNaOaR2kIa0"
  title="Building an Auth Flow with Expo Router"
  description="Learn how to implement an auth flow in your Expo Router project."
  className="mb-6"
/>
With Expo Router, all routes are always defined and accessible. You can use runtime logic to redirect users away from specific screens depending on whether they are authenticated. There are two different techniques for authenticating users within routes. This guide provides an example that demonstrates the functionality of standard native apps.
## Using React Context and Route Groups
It's common to restrict specific routes to users who are not authenticated. This is achievable in an organized way by using React Context and Route Groups. Consider the following project structure that has a `/sign-in` route that is always accessible and a `(app)` group that requires authentication:
```
âââ app/
    âââ _layout.tsx
    âââ sign-in.tsx  # Always accessible
    âââ (app)/
        âââ _layout.tsx  # Protects child routes
        âââ index.tsx  # Requires authorization
```
Step 1: 
To follow the above example, set up a [React Context provider](https://react.dev/reference/react/createContext) that can expose an authentication session to the entire app. You can implement your custom authentication session provider or use the one from the **Example authentication context** below.
Note: Example authentication context
---
This provider uses a mock implementation. You can replace it with your own [authentication provider](/guides/authentication/).
```tsx ctx.tsx
import { useContext, createContext, type PropsWithChildren } from 'react';
import { useStorageState } from './useStorageState';
const AuthContext = createContext<{
  signIn: () => void;
  signOut: () => void;
  session?: string | null;
  isLoading: boolean;
}>({
  signIn: () => null,
  signOut: () => null,
  session: null,
  isLoading: false,
});
// This hook can be used to access the user info.
export function useSession() {
  const value = useContext(AuthContext);
  if (!value) {
    throw new Error('useSession must be wrapped in a <SessionProvider />');
  }
  return value;
}
export function SessionProvider({ children }: PropsWithChildren) {
  const [[isLoading, session], setSession] = useStorageState('session');
  return (
    <AuthContext.Provider
      value={{
        signIn: () => {
          // Perform sign-in logic here
          setSession('xxx');
        },
        signOut: () => {
          setSession(null);
        },
        session,
        isLoading,
      }}>
      {children}
    </AuthContext.Provider>
  );
}
```
The following code snippet is a basic hook that persists tokens securely on native with [`expo-secure-store`](/versions/latest/sdk/securestore) and in local storage on web.
```tsx useStorageState.ts
import  { useEffect, useCallback, useReducer } from 'react';
import * as SecureStore from 'expo-secure-store';
import { Platform } from 'react-native';
type UseStateHook<T> = [[boolean, T | null], (value: T | null) => void];
function useAsyncState<T>(
  initialValue: [boolean, T | null] = [true, null],
): UseStateHook<T> {
  return useReducer(
    (state: [boolean, T | null], action: T | null = null): [boolean, T | null] => [false, action],
    initialValue
  ) as UseStateHook<T>;
}
export async function setStorageItemAsync(key: string, value: string | null) {
  if (process.env.EXPO_OS === 'web') {
    if (value === null) {
      localStorage.removeItem(key);
    } else {
      localStorage.setItem(key, value);
    }
  } else {
    if (value == null) {
      await SecureStore.deleteItemAsync(key);
    } else {
      await SecureStore.setItemAsync(key, value);
    }
  }
}
export function useStorageState(key: string): UseStateHook<string> {
  // Public
  const [state, setState] = useAsyncState<string>();
  // Get
  useEffect(() => {
    if (Platform.OS === 'web') {
      try {
        if (typeof localStorage !== 'undefined') {
          setState(localStorage.getItem(key));
        }
      } catch (e) {
        console.error('Local storage is unavailable:', e);
      }
    } else {
      SecureStore.getItemAsync(key).then((value: string | null) => {
        setState(value);
      });
    }
  }, [key]);
  // Set
  const setValue = useCallback(
    (value: string | null) => {
      setState(value);
      setStorageItemAsync(key, value);
    },
    [key]
  );
  return [state, setValue];
}
```
---
Step 2: 
Use the `SessionProvider` in the root layout to provide the authentication context to the entire app. It's imperative that the `<Slot />` is mounted before any navigation events are triggered. Otherwise, a runtime error will be thrown.
```tsx app/_layout.tsx
import { Slot } from 'expo-router';
import { SessionProvider } from '../ctx';
export default function Root() {
  // Set up the auth context and render our layout inside of it.
  return (
    <SessionProvider>
      <Slot />
    </SessionProvider>
  );
}
```
Step 3: 
Create a nested [layout route](/router/basics/layout/) that checks whether users are authenticated before rendering the child route components. This layout route redirects users to the sign-in screen if they are not authenticated.
```tsx app/(app)/_layout.tsx|collapseHeight=400
import { Text } from 'react-native';
import { Redirect, Stack } from 'expo-router';
import { useSession } from '../../ctx';
export default function AppLayout() {
  const { session, isLoading } = useSession();
  // You can keep the splash screen open, or render a loading screen like we do here.
  if (isLoading) {
    return <Text>Loading...</Text>;
  }
  // Only require authentication within the (app) group's layout as users
  // need to be able to access the (auth) group and sign in again.
  if (!session) {
    // On web, static rendering will stop here as the user is not authenticated
    // in the headless Node process that the pages are rendered in.
    return <Redirect href="/sign-in" />;
  }
  // This layout can be deferred because it's not the root layout.
  return <Stack />;
}
```
Step 4: 
Create the `/sign-in` screen. It can toggle the authentication using `signIn()`. Since this screen is outside the `(app)` group, the group's layout and authentication check do not run when rendering this screen. This lets logged-out users see this screen.
```tsx app/sign-in.tsx|collapseHeight=480
import { router } from 'expo-router';
import { Text, View } from 'react-native';
import { useSession } from '../ctx';
export default function SignIn() {
  const { signIn } = useSession();
  return (
    <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
      <Text
        onPress={() => {
          signIn();
          // Navigate after signing in. You may want to tweak this to ensure sign-in is
          // successful before navigating.
          router.replace('/');
        }}>
        Sign In
      </Text>
    </View>
  );
}
```
Step 5: 
Implement an authenticated screen that lets users sign out.
```tsx app/(app)/index.tsx|collapseHeight=480
import { Text, View } from 'react-native';
import { useSession } from '../../ctx';
export default function Index() {
  const { signOut } = useSession();
  return (
    <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
      <Text
        onPress={() => {
          // The `app/(app)/_layout.tsx` will redirect to the sign-in screen.
          signOut();
        }}>
        Sign Out
      </Text>
    </View>
  );
}
```
You now have an app that can present a loading state while it checks the initial authentication state and redirects to the sign-in screen if the user is not authenticated. If a user visits a deep link to any routes with the authentication check, they'll be redirected to the sign-in screen.
## Alternative loading states
With Expo Router, something must be rendered to the screen while loading the initial auth state. In the example above, the app layout renders a loading message. Alternatively, you can make the `index` route a loading state and move the initial route to something such as `/home`, which is similar to how X works.
## Modals and per-route authentication
Another common pattern is to render a sign-in modal over the top of the app. This enables you to dismiss and partially preserve deep links when the authentication is complete. However, this pattern requires routes to be rendered in the background as these routes require handling data loading without authentication.
```
âââ app/
    âââ _layout.tsx  # Declares global session context
    âââ (app)/
        âââ _layout.tsx
        âââ sign-in.tsx  # Modal presented over the root
        âââ (root)/
            âââ _layout.tsx  # Protects child routes
            âââ index.tsx  # Requires authorization
```
```tsx app/(app)/_layout.tsx|collapseHeight=480
import { Stack } from 'expo-router';
export const unstable_settings = {
  anchor: '(root)',
};
export default function AppLayout() {
  return (
    <Stack>
      <Stack.Screen name="(root)" />
      <Stack.Screen
        name="sign-in"
        options={{
          presentation: 'modal',
        }}
      />
    </Stack>
  );
}
```
## Navigating without navigation
You may encounter the following error when the app attempts to perform navigation without a navigator mounted in the [root layout](/router/basics/layout/#root-layout).
```text
Error: Attempted to navigate before mounting the Root Layout component. Ensure the Root Layout component is rendering a Slot, or other navigator on the first render.
```
To fix this, add a group and move conditional logic down a level.
### Before
```
âââ app/
    âââ _layout.tsx
    âââ about.tsx
```
```tsx app/_layout.tsx
export default function RootLayout() {
  React.useEffect(() => {
    // This navigation event will trigger the error above.
    router.push('/about');
  }, []);
  // This conditional statement creates a problem since the root layout's
  // content (the Slot) must be mounted before any navigation events occur.
  if (isLoading) {
    return <Text>Loading...</Text>;
  }
  return <Slot />;
}
```
### After
```
âââ app/
    âââ _layout.tsx
    âââ (app)/
        âââ _layout.tsx  # Move conditional logic down a level
        âââ about.tsx
```
```tsx app/_layout.tsx
export default function RootLayout() {
  return <Slot />;
}
```
```tsx app/(app)/_layout.tsx
export default function RootLayout() {
  React.useEffect(() => {
    router.push('/about');
  }, []);
  // It is OK to defer rendering this nested layout's content. We couldn't
  // defer rendering the root layout's content since a navigation event (the
  // redirect) would have been triggered before the root layout's content had
  // been mounted.
  if (isLoading) {
    return <Text>Loading...</Text>;
  }
  return <Slot />;
}
```
## Middleware
Traditionally, websites may leverage some form of server-side redirection to protect routes. Expo Router on the web currently only supports build-time static generation and has no support for custom middleware or serving. This can be added in the future to provide a more optimal web experience. In the meantime, authentication can be implemented by using client-side redirects and a loading state.


## Nesting navigators

Learn how to nest navigators in Expo Router.

> **warning** Navigation UI elements (Link, Tabs, Stack) may move out of the Expo Router library in the future.
<VideoBoxLink
  videoId="izZv6a99Roo"
  title="Using a Stack Navigator with Expo Router"
  description="Navigate between screens, pass params between screens, create dynamic routes, and configure the screen titles and animations."
  className="mb-6"
/>
Nesting navigators allow rendering a navigator inside the screen of another navigator. This guide is an extension of [React Navigation: Nesting navigators](https://reactnavigation.org/docs/nesting-navigators) to Expo Router. It provides an example of how nesting navigators work when using Expo Router.
## Example
Consider the following file structure which is used as an example:
```
âââ app/
    âââ _layout.tsx
    âââ index.tsx
    âââ home/
        âââ _layout.tsx
        âââ feed.tsx
        âââ messages.tsx
```
In the above example, **app/home/feed.tsx** matches `/home/feed`, and **app/home/messages.tsx** matches `/home/messages`.
```tsx app/_layout.tsx
import { Stack } from 'expo-router';
export default Stack;
```
Both **app/home/\_layout.tsx** and **app/index.tsx** below are nested in the **app/\_layout.tsx** layout so that it will be rendered as a stack.
```tsx app/home/_layout.tsx
import { Tabs } from 'expo-router';
export default Tabs;
```
```tsx app/index.tsx
import { Link } from 'expo-router';
export default function Root() {
  return <Link href="/home/messages">Navigate to nested route</Link>;
}
```
Both **app/home/feed.tsx** and **app/home/messages.tsx** below are nested in the **home/\_layout.tsx** layout, so it will be rendered as a tab.
```tsx app/home/feed.tsx
import { View, Text } from 'react-native';
export default function Feed() {
  return (
    <View>
      <Text>Feed screen</Text>
    </View>
  );
}
```
```tsx app/home/messages.tsx
import { View, Text } from 'react-native';
export default function Messages() {
  return (
    <View>
      <Text>Messages screen</Text>
    </View>
  );
}
```
## Navigate to a screen in a nested navigator
In React Navigation, navigating to a specific nested screen can be controlled by passing the screen name in params. This renders the specified nested screen instead of the initial screen for that nested navigator.
For example, from the initial screen inside the `root` navigator, you want to navigate to a screen called `media` inside `settings` (a nested navigator). In React Navigation, this is done as shown in the example below:
```jsx React Navigation
navigation.navigate('root', {
  screen: 'settings',
  params: {
    screen: 'media',
  },
});
```
In Expo Router, you can use `router.push()` to achieve the same result. There is no need to pass the screen name in the params explicitly.
```jsx Expo Router
router.push('/root/settings/media');
```


## Modals

Learn how to use modals in Expo Router.

<VideoBoxLink
  videoId="gNzuJVRmyDk"
  title="Using Modals with Expo Router"
  description="Learn about the different ways to display content over the rest of your app."
  className="mb-6"
/>
Modals are a common user interface pattern in mobile apps. They are used to present content on top of the existing screen and is used for different purposes, such as displaying confirmation alerts or standalone forms. You can create modals in your app using the following methods:
- Use React Native's [`Modal`](https://reactnative.dev/docs/modal) component.
- Use Expo Router's special file-based syntax to create a modal screen within the app's navigation system.
Each approach has its specific use case. Understanding when to use each method is important for creating a positive user experience.
## React Native's Modal component
The `Modal` component is part of React Native's core API. Common use cases include:
- Standalone interactions, such as self-contained tasks that don't need to be part of the navigation system.
- Temporary alerts or confirmation dialogs that are ideal for quick interactions.
Below is an example of a custom `Modal` component that overlays the current screen on different platforms:
For most use cases, you can use the `Modal` component and customize it according to your app's user interface requirements. For details on how to use the `Modal` component and its props, see the [React Native documentation](https://reactnative.dev/docs/modal).
## Modal screen using Expo Router
A modal screen is a file created inside the **app** directory and is used as a route within the existing stack. It is used for complex interactions that need to be part of the navigation system, such as multi-step forms where you can link to a specific screen after the process completes.
Below is an example of how a modal screen works on different platforms:
### Usage
To implement a modal route, create a screen called **modal.tsx** inside the **app** directory. Here's an example file structure:
```
âââ app/
    âââ _layout.tsx
    âââ index.tsx
    âââ modal.tsx
```
The above file structure produces a layout where the `index` is the first route in the stack. Inside the root layout file (**app/\_layout.tsx**), you can add the `modal` route in the stack. To present it as a modal, set the `presentation` option to `modal` on the route.
```tsx app/_layout.tsx
import { Stack } from 'expo-router';
export default function Layout() {
  return (
    <Stack>
      <Stack.Screen name="index" />
      <Stack.Screen
        name="modal"
        options={{
          presentation: 'modal',
        }}
      />
    </Stack>
  );
}
```
You can use the `Link` component to navigate to the modal screen from the **index.tsx** file.
```tsx app/index.tsx|collapseHeight=350
import { Link } from 'expo-router';
import { StyleSheet, Text, View } from 'react-native';
export default function Home() {
  return (
    <View style={styles.container}>
      <Text>Home screen</Text>
      <Link href="/modal" style={styles.link}>
        Open modal
      </Link>
    </View>
  );
}
const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },
  link: {
    paddingTop: 20,
    fontSize: 20,
  },
});
```
The **modal.tsx** presents the contents of the modal.
```tsx app/modal.tsx|collapseHeight=250
import { StyleSheet, Text, View } from 'react-native';
export default function Modal() {
  return (
    <View style={styles.container}>
      <Text>Modal screen</Text>
    </View>
  );
}
const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },
});
```
### Modal presentation and dismiss behavior
A modal loses its previous context when it is the current screen in the navigator and is presented as a standalone screen. Its presentation and dismissal behavior are different on each platform:
- On Android, the modal slides on top of the current screen. To dismiss it, use the back button to navigate back to the previous screen.
- On iOS, the modal slides from the bottom of the current screen. To dismiss it, swipe it down from the top.
- On web, the modal is presented as a separate route, and the dismiss behavior has to be provided manually using [`router.canGoBack()`](/router/navigating-pages/#imperative-navigation). Here's an example of how to dismiss the modal:
```tsx app/modal.tsx
import { StyleSheet, Text, View } from 'react-native';
export default function Modal() {
  const isPresented = router.canGoBack();
  return (
    <View style={styles.container}>
      <Text>Modal screen</Text>
      {isPresented && <Link href="../">Dismiss modal</Link>}
    </View>
  );
}
const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },
});
```
### Change status bar appearance on iOS
By default on iOS, the modal has a dark background which hides the status bar. To change the status bar appearance, you can use the `Platform` API to check if the current platform is iOS and then use the [`StatusBar`](/versions/latest/sdk/status-bar/) component to change the appearance inside the **modal.tsx** file.
```tsx app/modal.tsx|collapseHeight=250
import { StyleSheet, Text, View, Platform } from 'react-native';
import { StatusBar } from 'expo-status-bar';
export default function Modal() {
  return (
    <View style={styles.container}>
      <Text>Modal screen</Text>
      <StatusBar style={Platform.OS === 'ios' ? 'light' : 'auto'} />
    </View>
  );
}
const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },
});
```
### Handle deep-linked modals
While working with stack or nested stack navigators, modals need to be anchored to ensure correct navigation behavior. This is essential when deep-linking to modal routes. Without anchoring, the screen behind the modal will be wiped away, leaving no navigation context.
An _anchor_ serves as the base for the modal. In complex apps, when you have nested stacks, the anchor must be defined for the nested stack, and its value becomes the initial route of the stack.
You can configure an anchor by exporting `unstable_settings` from your stack's layout file:
```tsx
export const unstable_settings = {
  anchor: 'index', // Anchor to the index route
};
```
In the above example, the `anchor: 'index'` tells the Expo Router that it should maintain the specified anchor route in the background when presenting a modal.
## Form sheet presentation
Form sheet presents a modal as a bottom sheet that app users can drag between different heights (called detents). This is useful for content that needs partial screen coverage with interactive sizing.
### Basic usage
To use form sheet, set the `presentation` option to `formSheet` on your modal screen:
```tsx app/_layout.tsx
import { Stack } from 'expo-router';
export default function Layout() {
  return (
    <Stack>
      <Stack.Screen name="index" />
      <Stack.Screen
        name="modal"
        options={{
          presentation: 'formSheet',
        }}
      />
    </Stack>
  );
}
```
### Configuring sheet detents
Detents define the heights where the sheet can rest. Use `sheetAllowedDetents` to configure them:
- **Numeric array** (`number[]`): Specify snap positions as fractions of the screen height between 0 and 1. For example, `[0.25, 0.5, 1]` creates three snap points at 25%, 50%, and full screen height. Values must be sorted in ascending order.
- **Fit to contents** (`'fitToContents'`): The sheet automatically sizes itself based on its content. When using this option, you must provide explicit content sizing since `flex: 1` is not supported because the sheet needs to know the content's actual size to determine its height.
> **info** Android supports a maximum of 3 detents. iOS accepts any number of detents.
```tsx app/_layout.tsx
import { Stack } from 'expo-router';
export default function Layout() {
  return (
    <Stack>
      <Stack.Screen name="index" />
      <Stack.Screen
        name="modal"
        options={{
          presentation: 'formSheet',
          sheetAllowedDetents: [0.25, 0.5, 1],
          sheetInitialDetentIndex: 1,
        }}
      />
    </Stack>
  );
}
```
### Additional sheet options
| Option                            | Type                         | Description                                                |
| --------------------------------- | ---------------------------- | ---------------------------------------------------------- |
| `sheetInitialDetentIndex`         | `number \| 'last'`           | Index of the detent where the sheet opens (default: `0`).  |
| `sheetGrabberVisible`             | `boolean`                    | Shows a grabber handle at the top of the sheet (iOS only). |
| `sheetCornerRadius`               | `number`                     | Corner radius of the sheet in pixels.                      |
| `sheetLargestUndimmedDetentIndex` | `number \| 'none' \| 'last'` | Largest detent index that keeps the background undimmed.   |
```tsx app/_layout.tsx
import { Stack } from 'expo-router';
export default function Layout() {
  return (
    <Stack>
      <Stack.Screen name="index" />
      <Stack.Screen
        name="modal"
        options={{
          presentation: 'formSheet',
          sheetAllowedDetents: [0.25, 0.5, 1],
          sheetInitialDetentIndex: 0,
          sheetGrabberVisible: true,
          sheetCornerRadius: 24,
          sheetLargestUndimmedDetentIndex: 1,
        }}
      />
    </Stack>
  );
}
```
### Sheet footer (Android)
> **important** `unstable_sheetFooter` is an Android-only experimental feature that may change in future releases.
You can add a footer to the sheet that stays visible at all detent positions using a React component:
```tsx app/_layout.tsx
import { Stack } from 'expo-router';
import { View, Button } from 'react-native';
export default function Layout() {
  return (
    <Stack>
      <Stack.Screen name="index" />
      <Stack.Screen
        name="modal"
        options={{
          presentation: 'formSheet',
          sheetAllowedDetents: [0.5, 1],
          unstable_sheetFooter: () => (
            <View style={{ padding: 16, backgroundColor: 'white' }}>
              <Button title="Confirm" onPress={() => {}} />
            </View>
          ),
        }}
      />
    </Stack>
  );
}
```
### Using `flex: 1` with custom detents
> **info** In SDK 55 and later, `flex: 1` works correctly on iOS when using custom numeric detents. This does not work with `fitToContents` where you must provide explicit content sizing.
When using numeric detents, your modal content can use `flex: 1` to fill the available space within the sheet:
```tsx app/modal.tsx
import { StyleSheet, Text, View } from 'react-native';
export default function Modal() {
  return (
    <View style={styles.container}>
      <Text>Modal content</Text>
    </View>
  );
}
const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
    backgroundColor: 'white',
  },
});
```
## Additional information
### Presentation options
There are different options to present a modal screen using the `presentation` option on Android and iOS.
| Option                      | Description                                                                                                                                                                                                                      |
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `card`                      | The new screen will be pushed onto a stack. The default animation on Android will vary depending on the OS version and theme. On iOS, it will slide from the side.                                                               |
| `modal`                     | The new screen will be presented modally, allowing for a nested stack to be rendered inside the screen.                                                                                                                          |
| `transparentModal`          | The new screen will be presented modally, with the previous screen remaining visible. This allows the content below to still be seen if the screen has a translucent background.                                                 |
| `containedModal`            | On Android, fallbacks to `modal`. On iOS, uses [`UIModalPresentationCurrentContext`](https://developer.apple.com/documentation/uikit/uimodalpresentationstyle/uimodalpresentationcurrentcontext) modal style.                    |
| `containedTransparentModal` | On Android, fallbacks to `transparentModal`. On iOS, uses [`UIModalPresentationOverCurrentContext`](https://developer.apple.com/documentation/uikit/uimodalpresentationstyle/uimodalpresentationovercurrentcontext) modal style. |
| `fullScreenModal`           | On Android, fallbacks to `modal`. On iOS, uses [`UIModalPresentationFullScreen`](https://developer.apple.com/documentation/uikit/uimodalpresentationstyle/uimodalpresentationfullscreen) modal style.                            |
| `formSheet`                 | Presents a bottom sheet with configurable detents. See [FormSheet presentation](#form-sheet-presentation) for details.                                                                                                           |


## Web modals

Learn how to implement and customize the behavior of a modal in your web app using Expo Router.

> **important** Web modals are in alpha and available in SDK 54 and later. To use this feature, you must set the `EXPO_UNSTABLE_WEB_MODAL=1` environment variable in your project.
Modern web apps require a flexible modal experience that adapts to different content sizes and user interactions. Expo Router provides various modal presentation patterns for modern web experiences. These patterns leverage `presentation` with `modal`, `formSheet`, `transparentModal`, or `containedTransparentModal` to present either a modal based on different screen widths, and provide customizable styling props using `webModalStyle`.
## Get started
> **info** To use the new web modal features, you must set the `EXPO_UNSTABLE_WEB_MODAL=1` environment variable for both development and [export](/deploy/web/#export-your-web-project) builds. You can do this by adding it to your **.env** file at the root of your project or by prefixing your commands, for example: `EXPO_UNSTABLE_WEB_MODAL=1 npx expo start`.
Modals in Expo Router are configured using `Stack.Screen` component with specific options. This requires the modal screen to be added to the layout file of your app's `Stack`.
Consider the following navigation tree, which includes a stack navigator defined in the layout file, a home screen where the modal is accessed, and the modal screen component:
```
âââ app/
    âââ _layout.tsx
    âââ index.tsx
    âââ modal.tsx
```
In the layout file (**app/\_layout.tsx**), the modal screen component is added to the Stack navigator:
```tsx app/_layout.tsx
import { Stack } from 'expo-router';
export const unstable_settings = {
  anchor: 'index',
};
export default function Layout() {
  return (
    <Stack>
      <Stack.Screen name="index" />
      <Stack.Screen
        name="modal"
        options={{
          presentation: 'modal', // Enables modal behavior
          sheetAllowedDetents: [0.5, 1], // Array of snap positions for screens that have a width less than 768px.
        }}
      />
    </Stack>
  );
}
```
The **modal.tsx** is used to display the contents of a modal:
```tsx app/modal.tsx
import { Text, View } from 'react-native';
export default function Modal() {
  return <View style={{ flex: 1, padding: 16 }}>{/* Modal content goes here */}</View>;
}
```
Now, to open the modal from **index.tsx**, you can use `router.push('/modal')` in your index route:
```tsx app/index.tsx
import { router } from 'expo-router';
import { Pressable, Text, View, StyleSheet } from 'react-native';
export default function Home() {
  return (
    <View style={styles.container}>
      <Text style={styles.title}>Home Screen</Text>
      <Pressable onPress={() => router.push('/modal')} style={styles.button}>
        <Text style={styles.buttonText}>Open Modal</Text>
      </Pressable>
    </View>
  );
}
const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },
  title: {
    fontSize: 24,
    fontWeight: 'bold',
    marginBottom: 20,
  },
  button: {
    backgroundColor: '#007AFF',
    padding: 16,
    borderRadius: 8,
  },
  buttonText: {
    color: 'white',
    fontSize: 16,
    fontWeight: '600',
  },
});
```
Here's the result of the above example:
## Anchors and nested stacks
When working with stack or nested stack navigators, modals need to be properly anchored to ensure correct navigation behavior, especially when deep-linking to modal routes. Without anchoring, the screen behind the modal will be wiped away, leaving no navigation context.
An _anchor_ serves as the base for the modal. In complex apps, when you have nested stacks, the anchor must be defined for the nested stack, and its value becomes the initial route of the stack.
You can configure an anchor by exporting `unable_settings` from your stack's layout file:
```tsx
export const unstable_settings = {
  anchor: 'index', // Anchor to the index route
};
```
In the above example, the `anchor: 'index'` tells the Expo Router that it should maintain the specified anchor route in the background when presenting a modal.
## Modal presentation style
The difference between the presentation of how a modal appears in your web app on a large screen (for example, a desktop) while maintaining the sheet behavior when the web app runs on a mobile device, depends on the configuration options. The following are options available for configuring a web modal's appearance that can be passed to the `options` object of a `Stack.Screen`.
| Option                | Type                                                                          | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| --------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `presentation`        | `'modal'`, `'formSheet'`, `'transparentModal'`, `'containedTransparentModal'` | Modal presentation style. On screens with width more than 768px, all styles display as a centered overlay (for example, a lightbox).  On screens with width less than `768px`, `formSheet` is used to display as a bottom sheet. When set to `transparentModal`, it displays as an overlay without a completely obscure background content. Detents and sheet grabber properties are not applied. This presentation is useful when building your own custom modal.  Similar to `transparentModal`, when set to `containedTransparentModal`, it displays as an overlay without a completely obscure background content. Detents and other properties are not applied. This presentation is useful when building your own custom modal. |
| `sheetAllowedDetents` | `number[]`, `'fitToContents'`                                                 | Snap positions as percentages (0.0-1.0) or automatic fitting. Only applies to screens with less than `768px` width.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `sheetGrabberVisible` | `boolean`                                                                     | **On iOS, s**hows/hides the drag handle at the top of the sheet. Not supported on Android and web. We recommend using a custom sheet header component to imitate the grabber across all platforms.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `sheetCornerRadius`   | `number`                                                                      | Corner radius of the sheet in pixels.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `webModalStyle`       | `WebModalStyle`                                                               | Special prop that allows web-specific styling options for fine-tuning modal appearance.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
## Custom modal styling with `webModalStyle`
> **info** **Note:** The `webModalStyle` properties only apply to web platforms. On mobile, the modal will automatically adapt to use sheet-like behavior for touch interaction.
You can use `webModalStyle` to customize the dimensions and appearance of your modals on web. It provides the following properties for further customization:
| Property            | Type              | Description                                                                                                                    | Default                |
| ------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------ | ---------------------- |
| `width`             | `number` `string` | Override the width of the modal (px or percentage). Only applies for web platform on a desktop.                                | `83vw`                 |
| `height`            | `number` `string` | Override the height of the modal (px or percentage). Only applies for web platform on a desktop.                               | `79vh`                 |
| `minHeight`         | `number` `string` | Minimum height of the desktop modal (px or percentage). Overrides the default iOS 26 sizing.                                   | `min(586px, 79vh)`     |
| `minWidth`          | `number` `string` | Minimum width of the desktop modal (px or percentage). Overrides the default iOS 26 sizing.                                    | `min(936px, 83vw)`     |
| `border`            | `string`          | Override the border of the desktop modal (any valid CSS border value, for example, '1px solid #ccc' or 'none')                 | None                   |
| `overlayBackground` | `string`          | Override the overlay background color (any valid CSS color or rgba/hsla value).                                                | Semi-transparent black |
| `shadow`            | `string`          | Override the modal shadow filter (any valid CSS filter value, for example, 'drop-shadow(0 4px 8px rgba(0,0,0,0.1))' or 'none') | Drop-shadow filter     |
### Custom CSS properties
Expo Router uses custom CSS properties to style modals, which you can override globally using `webModalStyle`. These variables provide fine-grained control over a modal's appearance.
#### Width and height sizing variables
```css
/* Default modal width (83vw on desktop, following iOS 26 specifications) */
--expo-router-modal-width: 83vw;
/* Maximum modal width (936px max, 83vw by default, following iOS 26) */
--expo-router-modal-max-width: min(936px, 83vw);
/* Minimum modal width (auto by default) */
--expo-router-modal-min-width: auto;
/* Default modal height (79vh, following iOS 26 specifications) */
--expo-router-modal-height: 79vh;
/* Minimum modal height (586px max, 79vh by default, following iOS 26) */
--expo-router-modal-min-height: min(586px, 79vh);
```
#### Border and overlay styling variables
```css
/* Modal border (none by default) */
--expo-router-modal-border: none;
/* Modal corner radius (24px by default, following iOS 26) */
--expo-router-modal-border-radius: 24px;
/* Modal shadow filter (drop-shadow by default) */
--expo-router-modal-shadow: drop-shadow(0 10px 8px rgb(0 0 0 / 0.04))
  drop-shadow(0 4px 3px rgb(0 0 0 / 0.1));
/* Overlay background color (25% black by default) */
--expo-router-modal-overlay-background: rgba(0, 0, 0, 0.25);
```
#### How `webModalStyle` maps to CSS variables
When you use `webModalStyle` to override any of the sizing variables, Expo Router automatically sets these CSS variables to the values you provide:
```tsx
// This webModalStyle configuration
webModalStyle: {
  width: 800,
  height: 600,
  border: '2px solid blue',
  overlayBackground: 'rgba(0, 0, 0, 0.7)',
  shadow: 'drop-shadow(0 8px 16px rgba(0,0,0,0.2))',
}
// ...automatically sets these CSS variables:
// --expo-router-modal-width: 800px
// --expo-router-modal-height: 600px
// --expo-router-modal-border: 2px solid blue
// --expo-router-modal-overlay-background: rgba(0, 0, 0, 0.7)
// --expo-router-modal-shadow: drop-shadow(0 8px 16px rgba(0,0,0,0.2))
```
## Common examples
Note: Full screen modal example
---
To create a full screen modal for content that covers maximum space, you can use `webModalStyle` property in your modal route's `Stack.Screen` options:
```tsx app/_layout.tsx
import { Stack } from 'expo-router';
export const unstable_settings = {
  anchor: 'index',
};
export default function Layout() {
  return (
    <Stack>
      <Stack.Screen name="index" />
      <Stack.Screen
        name="modal"
        options={{
          presentation: 'modal',
          webModalStyle: {
            width: '95vw',
            height: '95vh',
            border: 'none',
          },
        }}
      />
    </Stack>
  );
}
```
Here's the result of the above example:
When running your web app on mobile devices, you can set `sheetAllowedDetents` to `fitToContents` or a custom value if you want to avoid showing a full screen modal:
```tsx app/_layout.tsx
import { Stack } from 'expo-router';
export const unstable_settings = {
  anchor: 'index',
};
export default function Layout() {
  return (
    <Stack>
      <Stack.Screen name="index" />
      <Stack.Screen
        name="modal"
        options={{
          presentation: 'modal',
          webModalStyle: {
            width: '95vw',
            height: '95vh',
            border: 'none',
          },
          sheetAllowedDetents: 'fitToContents',
        }}
      />
    </Stack>
  );
}
```
The modal appears as a sheet on a mobile device:
---
Note: Compact modal example
---
For smaller interactions, you can create a compact modal that fits its content:
```tsx app/_layout.tsx
import { Stack } from 'expo-router';
export const unstable_settings = {
  anchor: 'index',
};
export default function Layout() {
  return (
    <Stack>
      <Stack.Screen name="index" />
      <Stack.Screen
        name="modal"
        options={{
          presentation: 'modal',
          webModalStyle: {
            width: 400,
            height: 'auto',
            minHeight: 200,
            border: '1px solid #e5e7eb',
            overlayBackground: 'rgba(0, 0, 0, 0.3)',
          },
          sheetCornerRadius: 12,
          sheetAllowedDetents: 'fitToContents',
        }}
      />
    </Stack>
  );
}
```
Here's the result of the above example:
---
Note: Transparent modal example
---
You can set the `presentation` option to `transparentModal` when you want to display an overlay that should maintain the visual context of the underlying screen:
```tsx app/_layout.tsx
import { Stack } from 'expo-router';
export const unstable_settings = {
  anchor: 'index',
};
export default function Layout() {
  return (
    <Stack>
      <Stack.Screen name="index" />
      <Stack.Screen
        name="modal"
        options={{
          presentation: 'transparentModal',
        }}
      />
    </Stack>
  );
}
```
Here's the result of the above example:
---
Note: Corner radius example
---
You customize the corner radius using `sheetCornerRadius`:
```tsx app/_layout.tsx
import { Stack } from 'expo-router';
export const unstable_settings = {
  anchor: 'index',
};
export default function Layout() {
  return (
    <Stack>
      <Stack.Screen name="index" />
      <Stack.Screen
        name="modal"
        options={{
          presentation: 'formSheet',
          sheetAllowedDetents: [0.4],
          sheetCornerRadius: 32,
        }}
      />
    </Stack>
  );
}
```
Here's the result of the above example:
---
Note: Custom detents example
---
You can use `sheetAllowedDetents` to define the height at which the modal can rest:
```tsx app/_layout.tsx
import { Stack } from 'expo-router';
export const unstable_settings = {
  anchor: 'index',
};
export default function Layout() {
  return (
    <Stack>
      <Stack.Screen name="index" />
      <Stack.Screen
        name="modal"
        options={{
          presentation: 'formSheet',
          sheetAllowedDetents: [0.2, 0.5, 0.8, 0.98],
        }}
      />
    </Stack>
  );
}
```
Here's the result of the above example:
---
## Global CSS customization
For your web app, if you are using a [global CSS](https://docs.expo.dev/versions/latest/config/metro/#global-css) file in your project, you can also override width, height, border, and overlay variables.
You can add custom values using the `--expo-router-*` variables in your global CSS file:
```css
/* Override default modal styling globally */
:root {
  --expo-router-modal-width: 700px;
  --expo-router-modal-min-width: auto;
  --expo-router-modal-max-width: 95vw;
  --expo-router-modal-height: 640px;
  --expo-router-modal-min-height: 640px;
  --expo-router-modal-border: none;
  --expo-router-modal-border-radius: 16px;
  --expo-router-modal-shadow: drop-shadow(0 8px 16px rgba(0, 0, 0, 0.2));
  --expo-router-modal-overlay-background: rgba(0, 0, 0, 0.5);
}
```
## Custom modal route implementation
{/* TODO (@aman): When publishing this guide, remove "Web modals implementation" from the modals.tsx file. */}
The video above demonstrates a modal window that appears over the main content of the web page. The background dims to draw focus to the modal, which contains information for the user. This is typical behavior for web modals, where users can interact with the modal or close it to return to the main page.
You can achieve the above web modal behavior by using the [`transparentModal`](https://reactnavigation.org/docs/stack-navigator/#transparent-modals) presentation mode, styling the overlay and modal content, and utilizing [`react-native-reanimated`](/versions/latest/sdk/reanimated/#installation) to animate the modal's presentation.
Modify your project's root layout (**app/\_layout.tsx**) to add an `options` object to the modal route:
```tsx app/_layout.tsx
import { Stack } from 'expo-router';
export const unstable_settings = {
  initialRouteName: 'index',
};
export default function Layout() {
  return (
    <Stack>
      <Stack.Screen name="index" />
      <Stack.Screen
        name="modal"
        options={{
          presentation: 'transparentModal',
          animation: 'fade',
          headerShown: false,
        }}
      />
    </Stack>
  );
}
```
> **info** **Note:** `unstable_settings` currently works only with `Stack` navigators.
The above example sets the `index` screen as the [`initialRouteName`](/router/advanced/router-settings/#initialroutename) using [`unstable_settings`](/router/advanced/router-settings). This ensures that the transparent modal is always rendered on top of the current screen, even when users navigate to the modal screen via a direct link.
Style the overlay and modal content in **modal.tsx** as shown below:
```tsx app/modal.tsx|collapseHeight=250
import { Link } from 'expo-router';
import { Pressable, StyleSheet, Text } from 'react-native';
import Animated, { FadeIn, SlideInDown } from 'react-native-reanimated';
export default function Modal() {
  return (
    <Animated.View
      entering={FadeIn}
      style={{
        flex: 1,
        justifyContent: 'center',
        alignItems: 'center',
        backgroundColor: '#00000040',
      }}
    >
      {/* Dismiss modal when pressing outside */}
      <Link href={'/'} asChild>
        <Pressable style={StyleSheet.absoluteFill} />
      </Link>
      <Animated.View
        entering={SlideInDown}
        style={{
          width: '90%',
          height: '80%',
          alignItems: 'center',
          justifyContent: 'center',
          backgroundColor: 'white',
        }}
      >
        <Text style={{ fontWeight: 'bold', marginBottom: 10 }}>Modal Screen</Text>
        <Link href="/">
          <Text>â Go back</Text>
        </Link>
      </Animated.View>
    </Animated.View>
  );
}
```
You can customize the modal's appearance as per your needs.


## Shared routes

Learn how to define shared routes or use arrays to use the same route multiple times with different layouts using Expo Router.

To match the same URL with different layouts, use [**groups**](/router/basics/notation/#parentheses) with overlapping child routes. This pattern is very common in native apps. For example, in the X app, a profile can be viewed in every tab (such as home, search, and profile). However, there is only one URL that is required to access this route.
In the example below, **app/\_layout.tsx** is the tab bar and each route has its own header. The **app/(profile)/[user].tsx** route is shared between each tab.
```
âââ app/
    âââ _layout.tsx
    âââ (home)/
    â   âââ _layout.tsx
    â   âââ [user].tsx
    âââ (search)/
    â   âââ _layout.tsx
    â   âââ [user].tsx
    âââ (profile)/
        âââ _layout.tsx
        âââ [user].tsx
```
> When reloading the page, the first alphabetical match is rendered.
Shared routes can be navigated directly by including the group name in the route. For example, `/(search)/baconbrix` navigates to `/baconbrix` in the "search" layout.
## Arrays
> Array syntax is an advanced concept that is unique to native app development.
Instead of defining the same route multiple times with different layouts, use the array syntax `(,)` to duplicate the children of a group. For example, `app/(home,search)/[user].tsx` &mdash; creates `app/(home)/[user].tsx` and `app/(search)/[user].tsx` in memory.
To distinguish between the two routes use a layout's `segment` prop:
```tsx app/(home,search)/_layout.tsx
export default function DynamicLayout({ segment }) {
  if (segment === '(search)') {
    return <SearchStack />;
  }
  return <Stack />;
}
```
To enable the **array syntax**, specify the [`initialRouteName`](/router/advanced/router-settings/#initialroutename) for each group using `unstable_settings` object in the dynamic layout:
```tsx app/(home,search)/_layout.tsx
export const unstable_settings = {
  initialRouteName: 'home',
  search: {
    initialRouteName: 'search',
  },
};
export default function DynamicLayout({ segment }) {
}
```
In the above example, the `home` route is the default route for the `home` group and the app. The `search` route is the default route for the `search` group.
## Key points
- You can only provide groups for the current navigator.
- When using the array syntax, if there are two groups (for example, `(one)/(two)`), only the last group's segment is used for matching the route.
- If there are at least two group `initialRouteNames`, but a default `initialRouteName` is not provided, the first group's `initialRouteName` is used.


## Protected routes

Learn how to make screens inaccessible to client-side navigation.

> **warning** Protected routes are available in SDK 53 and later.
Video Tutorial: [Watch: Using protected routes](https://www.youtube.com/watch?v=zHZjJDTTHJg)
## Overview
Protected screens allow you to prevent users from accessing certain routes using client-side navigation. If a user tries to navigate to a protected screen, or if a screen becomes protected while it is active, they will be redirected to the anchor route (usually the index screen) or the first available screen in the stack.
```
âââ app/
    âââ _layout.tsx
    âââ index.tsx
    âââ about.tsx
    âââ login.tsx  # Should only be available while not authenticated
    âââ private/
        âââ _layout.tsx  # Should only be available while authenticated
        âââ index.tsx
        âââ page.tsx
```
```tsx app/_layout.tsx
import { Stack } from 'expo-router';
const isLoggedIn = false;
export function AppLayout() {
  return (
    <Stack>
      <Stack.Protected guard={!isLoggedIn}>
        <Stack.Screen name="login" />
      </Stack.Protected>
      <Stack.Protected guard={isLoggedIn}>
        <Stack.Screen name="private" />
      </Stack.Protected>
      {/* Expo Router includes all routes by default. Adding Stack.Protected creates exceptions for these screens. */}
    </Stack>
  );
}
```
In this example, the `/private` route is inaccessible because the `guard` is false. When a user attempts to access `/private`, they are redirected to the anchor route, which is the **index** screen.
Additionally, if the user is on `/private/page` and the `guard` condition changes to **false**, they will be redirected automatically.
When a screen's **guard** is changed from **true** to **false**, all of its history entries will be removed from the navigation history.
## Multiple protected screens
In Expo Router, a screen can **only exist in one active route group at a time**.
You should only declare a screen only once, in the most appropriate group or stack. If a screen's availability depends on logic, wrap it in a conditional group instead of duplicating the screen.
```tsx app/_layout.tsx
import { Stack } from 'expo-router';
const isLoggedIn = true;
const isAdmin = true;
export function AppLayout() {
  return (
    <Stack>
      <Stack.Protected guard={true}>
        <Stack.Screen name="profile" />
      </Stack.Protected>
      <Stack.Screen name="profile" /> // â Not allowed: duplicate screen
    </Stack>
  );
}
```
## Nesting protected screens
Protected screens can be nested to define hierarchical access control logic.
```tsx app/_layout.tsx
import { Stack } from 'expo-router';
const isLoggedIn = true;
const isAdmin = true;
export function AppLayout() {
  return (
    <Stack>
      <Stack.Protected guard={isLoggedIn}>
        <Stack.Protected guard={isAdmin}>
          <Stack.Screen name="private" />
        </Stack.Protected>
        <Stack.Screen name="about" />
      </Stack.Protected>
    </Stack>
  );
}
```
In this case:
- `/private` is only protected if the user is logged in and is an admin.
- `/about` is protected to any logged-in user.
## Falling back to a specific screen
You can configure the navigator to fall back to a specific screen if access is denied.
```
âââ app/
    âââ _layout.tsx
    âââ index.tsx
    âââ about.tsx
    âââ login.tsx
    âââ private/
        âââ _layout.tsx
        âââ index.tsx
        âââ page.tsx
```
```tsx app/_layout.tsx
import { Stack } from 'expo-router';
const isLoggedIn = false;
export function AppLayout() {
  return (
    <Stack>
      <Stack.Protected guard={isLoggedIn}>
        <Stack.Screen name="index" />
        <Stack.Screen name="private" />
      </Stack.Protected>
      <Stack.Screen name="login" />
    </Stack>
  );
}
```
In the above example, since the **index** screen is protected and the `guard` is **false**, the router redirects to the first available screen &mdash; **login**.
## Tabs and Drawer
Protected routes are also available for [Tabs](/router/advanced/tabs/) and [Drawer](/router/advanced/drawer/) navigators.
```tsx app/_layout.tsx
import { Tabs } from 'expo-router';
const isLoggedIn = false;
export default function TabLayout() {
  return (
    <Tabs>
      <Tabs.Screen name="index" options={{ tabBarLabel: 'Home' }} />
      <Tabs.Protected guard={isLoggedIn}>
        <Tabs.Screen name="private" options={{ tabBarLabel: 'Private' }} />
        <Tabs.Screen name="profile" options={{ tabBarLabel: 'Profile' }} />
      </Tabs.Protected>
      <Tabs.Protected guard={!isLoggedIn}>
        <Tabs.Screen name="login" options={{ tabBarLabel: 'Login' }} />
      </Tabs.Protected>
    </Tabs>
  );
}
```
## Custom navigators
`Protected` is also available for [custom navigators](/router/migrate/from-react-navigation/#rewrite-custom-navigators) using the `withLayoutContext` hook.
## Static rendering considerations
Protected screens are evaluated on the client side only. During static site generation, no HTML files are created for protected routes. However, if users know the URLs of these routes, they can still request the corresponding HTML or JavaScript files directly. Protected screens are not a replacement for server-side authentication or access control.