# Advanced

## Platform-specific extensions and module

Learn how to switch modules based on the platform in Expo Router using platform-specific extensions and Platform module from React Native.

While building your app, you may want to show specific content based on the current platform. Platform-specific extensions and `Platform` module can make the experience more native to a given platform. The following sections describe the ways you can achieve this with Expo Router.
## Platform-specific extensions
> **warning** Platform-specific extensions were added in Expo Router `3.5.x`. If you are using an older version of the library, follow instructions from [Platform-specific modules](#platform-module).
There are two ways to use platform-specific extensions:
### Within app directory
Metro bundler's platform-specific extensions (for example, **.android.tsx**, **.ios.tsx**, **.native.tsx**, or **.web.tsx**) are supported in the **app** directory only if a **non-platform version** also exists. This ensures that routes are universal across platforms for deep linking.
Consider the following project structure:
```
âââ app/
    âââ _layout.tsx
    âââ _layout.web.tsx
    âââ index.tsx
    âââ about.tsx
    âââ about.web.tsx
```
In the above file structure:
- **\_layout.web.tsx** file is used as a layout on the web and **\_layout.tsx** is used on all other platforms.
- **index.tsx** file is used as the home page for all platforms.
- **about.web.tsx** file is used as the about page for the web, and the **about.tsx** file is used on all other platforms.
### Outside app directory
You can create platform-specific files with extensions (for example, **.android.tsx**, **.ios.tsx**, **.native.tsx**, or **.web.tsx**) outside the **app** directory and use them from within the **app** directory.
Consider the following project structure:
```
âââ app/
â   âââ _layout.tsx
â   âââ index.tsx
â   âââ about.tsx
âââ components/
    âââ about.tsx
    âââ about.ios.tsx
    âââ about.web.tsx
```
In the above file structure, the designs require you to build different `about` screens for each platform. In that case, you can create a component for each platform in the **components** directory using platform extensions. When imported, Metro will ensure the correct component version is used based on the current platform. You can then re-export the component as a screen in the **app** directory.
```tsx app/about.tsx
export { default } from '../components/about';
```
## Platform module
You can use the [`Platform`](https://reactnative.dev/docs/platform-specific-code#platform-module) module from React Native to detect the current platform and render the appropriate content based on the result. For example, you can render a `Tabs` layout on native and a custom layout on the web.
```tsx app/_layout.tsx
import { Platform } from 'react-native';
import { Link, Slot, Tabs } from 'expo-router';
export default function Layout() {
  if (Platform.OS === 'web') {
    // Use a basic custom layout on web.
    return (
      <div style={{ flex: 1 }}>
        <header>
          <Link href="/">Home</Link>
          <Link href="/settings">Settings</Link>
        </header>
        <Slot />
      </div>
    );
  }
  // Use a native bottom tabs layout on native platforms.
  return (
    <Tabs>
      <Tabs.Screen name="index" options={{ title: 'Home' }} />
      <Tabs.Screen name="settings" options={{ title: 'Settings' }} />
    </Tabs>
  );
}
```


## Customizing links

Learn how to perform link redirection and utilize third-party deep links with +native-intent when using Expo Router.

Expo Router uses an extended version of web standards to navigate through an app. However, native apps do not always conform to server-based routing. This can lead to misalignment when integrating any third-party service. For example, apps can be launched with arbitrary strings or intent objects instead of URLs. There are two common scenarios where you may need to customize a link:
- **App Closed**: When the app is not open, incoming deep-link URLs may require rewriting to ensure seamless navigation.
- **App Open**: When the app is already open, URL customization may be necessary based on specific business logic or user interactions. This logic can be global for the entire app, or localized to a set of routes.
## Setup linking
See the [Linking into your app](/linking/into-your-app/) guide for instructions on how to set up and test linking in your app.
## Rewrite incoming native deep links
Expo Router will always evaluate a URL with the assumption that the URL targets a specific page within the app. However, in practice, these URLs can vary in nature:
- **Unique/Referred URLs from third-party providers**: These URLs often follow a specific schema, such as `<my-scheme>://<provider-hostname>/<uuid>`, and are generated by external sources to navigate users to designated content within the app.
- **Stale URLs from previous versions**: App users might encounter URLs from older versions of the app, which could lead to outdated or non-existent content. It's crucial to handle such scenarios gracefully to ensure a smooth user experience.
In such scenarios, the URL needs to be rewritten to correctly target a route.
To facilitate this, create a special file called **+native-intent.tsx** at the top level of your project's **app** directory. This file exports a special [`redirectSystemPath`](/versions/latest/sdk/router/#nativeintent) method designed to handle URL/path processing. When invoked, it receives an `options` object with two attributes: `path` and `initial`.
```
âââ app/
    âââ +native-intent.tsx
```
Here's an example the applies practices on how `redirectSystemPath` is used inside **+native-intent.tsx** file. Following this example, you can ensure the stability and reliability of your app's URL processing functionality and mitigate the risk of unexpected errors and crashes.
```ts app/+native-intent.tsx
import ThirdPartyService from 'third-party-sdk';
export function redirectSystemPath({ path, initial }) {
  try {
    if (initial) {
      // While the parameter is called `path` there is no guarantee that this is a path or a valid URL
      const url = new URL(path, 'myapp://app.home');
      // Detection of third-party URLs will change based on the provider
      if (url.hostname === '<third-party-provider-hostname>') {
        return ThirdPartyService.processReferringUrl(url).catch(() => {
          // Something went wrong
          return '/unexpected-error';
        });
      }
      return path;
    }
    return path;
  } catch {
    // Do not crash inside this function! Instead you should redirect users
    // to a custom route to handle unexpected errors, where they are able to report the incident
    return '/unexpected-error';
  }
}
```
## Rewrite incoming web deep links
Handling deep links on the web differs from native platforms, as the initial routing process occurs differently. Expo Router cannot provide a direct counterpart to `+native-intent` for web, as web routing is resolved before the website's JavaScript is executed and will differ based upon deployment output and your chosen provider.
As a result, you should implement one of the following patterns that best suits your requirements:
- **Server Redirect**: Since all websites, including static pages, are hosted on a server, consider leveraging server-side redirection or middleware options provided by your deployment provider. This approach is well-suited for deployments targeting **server** or **static** outputs.
- **Client Redirect**: Alternatively, you can manage URL redirects within your app's root `_layout`. This approach is ideal for projects with a single output format targeting client-side rendering.
Choose the pattern that aligns with your deployment strategy and technical requirements to ensure seamless handling of incoming deep links on the web platform.
## Rewrite URLs
While your app is open, you can react to URL changes within your `_layout` files using the `usePathname()` hook. The location of the `_layout` dictates the scope of the subscription.
- **global**: Add the logic to your root `_layout` file
- **localized**: Add a `_layout` file to an existing directory (or create a new [group directory](/router/basics/notation/#parentheses))
```tsx app/_layout.tsx
import { Slot, Redirect } from 'expo-router';
export default function RootLayout() {
  const pathname = usePathname();
  if (pathname && !isUserAllowed(pathname)) {
    return <Redirect href="/home" />;
  }
  return <Slot />;
}
```
### Using `redirectSystemPath`
In native apps, an alternative way to rewrite a URL is to handle it within the [`redirectSystemPath`](#redirectsystempath) method. This approach can be simpler for some use cases but comes with certain drawbacks:
- **Native-only**: This method will not work on the web, as `+native-intent` is only available in native apps.
- **Lacks context**: `+native-intent` is processed outside the context of your app. This means you won't have access to additional logic, such as user authentication status or the current route's state.
## Sending navigation events to third-party services
Below is a basic example of how to send navigation events to an external service, such as an analytics or logging service. Consult with your provider for specific instructions.
```tsx app/_layout.tsx
import ThirdPartyService from 'third-party-sdk';
import { Slot, usePathname } from 'expo-router';
const thirdParty = new ThirdPartyService();
export default function RootLayout() {
  const pathname = usePathname();
  // Perform the service initiation logic
  useEffect(() => {
    thirdParty.register();
    return () => {
      thirdParty.deregister();
    };
  }, [thirdParty]);
  // Send pathname changes to the third party
  useEffect(() => {
    thirdParty.sendEvent({ pathname });
  }, [pathname]);
  return <Slot />;
}
```
## Universal Links and multiple domains
Expo Router does not require additional configuration for Universal Links and multiple domains. All URLs provided to your App will be evaluated. To customize the URL scheme(s) for your app, your should [customize the `scheme` value in your app config](/versions/latest/config/app/#scheme).
## Forcing web links
If you want a URL to be initially evaluated by the user's browser, write the address as a Fully Qualified URL (FQDN) with an `http`/`https` scheme. Using a complete URL ensures that the link is interpreted as a web URL and opened in the user's browser by default.
This approach is effective for directing users to external websites or Universal Links for other apps.
```ts
<Link href="https://my-website.com/router/introduction" />
```
## `legacy_subscribe`
> **important** `legacy_subscribe` is in alpha and is available in SDK 52.
If you're using a third-party provider that doesn't support Expo Router but does support React Navigation via the `Linking.subscribe` function for existing projects, you can use `legacy_subscribe` as an alternative API.
Using this API is not recommended for new projects or integrations. Its usage is incompatible with Server Side Routing and [Static Rendering](/router/web/static-rendering/), and can be challenging to manage while offline or in a low network connectivity environment.


## Router settings

Learn how to configure layouts with static properties in Expo Router.

> **warning** **Warning:** `unstable_settings` currently do not work with [async routes](/router/web/async-routes/) (development-only). This is why the feature is designated _unstable_.
#### initialRouteName
When deep linking to a route, you may want to provide a user with a "back" button. The `initialRouteName` sets the default screen of the stack and should match a valid filename (without the extension).
```
âââ app/
    âââ _layout.tsx
    âââ index.tsx
    âââ other.tsx
```
```tsx app/_layout.tsx
import { Stack } from 'expo-router';
export const unstable_settings = {
  // Ensure any route can link back to `/`
  initialRouteName: 'index',
};
export default function Layout() {
  return <Stack />;
}
```
Now deep linking directly to `/other` or reloading the page will continue to show the back arrow.
When using [array syntax](/router/advanced/shared-routes/#arrays) `(foo,bar)` you can specify the name of a group in the `unstable_settings` object to target a particular segment.
```tsx other.tsx
export const unstable_settings = {
  // Used for `(foo)`
  initialRouteName: 'first',
  // Used for `(bar)`
  bar: {
    initialRouteName: 'second',
  },
};
```
The `initialRouteName` is only used when deep-linking to a route. During app navigation, the route you are navigating to will be the initial route. You can disable this behavior using the `initial` prop on the `<Link />` component or by passing the option to the imperative APIs.
```js
// If this navigates to a new _layout, don't override the initial route
<Link href="/route" initial={false} />;
router.push('/route', { overrideInitialScreen: false });
```


## Apple Handoff

Learn how to seamlessly continue app navigation across Apple devices with Expo Router and Apple Handoff.

Apple Handoff is a feature that enables users to continue browsing your app or website on another device. Expo Router automates all of the runtime routing for this feature. However, the one-time configuration must be set up manually.
In Expo Router, the underlying iOS API (`NSUserActivity`) requires a `webpageUrl` which the OS recommends as the current URL for switching to your app. The `expo-router/head` component has an optional native module that can automatically set the `webpageUrl` to the currently focused route in Expo Router.
## Setup
The following restrictions and considerations are important:
- Handoff is Apple-only.
- Handoff can not be used in the Expo Go app as it requires build-time configuration.
- Handoff requires [universal links](/linking/into-your-app/) to be configured, at least on iOS, and contain the `activitycontinuation` object.
- Handoff requires the `expo-router/head` component to be used on each page that you want to support, or in the root layout if you want all pages to be continuous.
To ensure that the **public/.well-known/apple-app-site-association** file is configured correctly, it must include the `activitycontinuation` key with an `apps` array that contains your app's bundle ID and Team ID formatted as `<APPLE_TEAM_ID>.<IOS_BUNDLE_ID>`. For example, `QQ57RJ5UTD.app.expo.acme` where `QQ57RJ5UTD` is the Team ID and `app.expo.acme` is the bundle identifier.
```json public/.well-known/apple-app-site-association
{
  "applinks": {
    "details": [
      {
        "appIDs": ["<APPLE_TEAM_ID>.<IOS_BUNDLE_ID>"],
        "components": [
          {
            "/": "*",
            "comment": "Matches all routes"
          }
        ]
      }
    ]
  },
  "activitycontinuation": {
    "apps": ["<APPLE_TEAM_ID>.<IOS_BUNDLE_ID>"]
  },
  "webcredentials": {
    "apps": ["<APPLE_TEAM_ID>.<IOS_BUNDLE_ID>"]
  }
}
```
> The `webcredentials` object is optional but recommended.
You can use the following command to generate the **apple-app-site-association** file based on your [app config](/versions/latest/config/app/):
```sh
$ npx setup-safari
```
See [Test the deep link](/linking/into-your-app/#test-the-deep-link) guide to test handoff in development.
### Expo Head setup
Ensure you set the Handoff origin in your `app.config.tsx` file using the `expo-router` config plugin. This is the URL that will be used for the `webpageUrl` when the user switches to your app.
```tsx app.config.tsx
// Be sure to change this to be unique to your project.
process.env.EXPO_TUNNEL_SUBDOMAIN = 'bacon-router-sandbox';
const ngrokUrl = `${process.env.EXPO_TUNNEL_SUBDOMAIN}.ngrok.io`;
/** @type {import('expo/config').ExpoConfig} */
module.exports = {
  // ...
  ios: {
    associatedDomains: [
      `applinks:${ngrokUrl}`,
      `activitycontinuation:${ngrokUrl}`,
      `webcredentials:${ngrokUrl}`,
      // Add additional production-URLs here.
      // `applinks:example.com`,
      // `activitycontinuation:example.com`,
      // `webcredentials:example.com`,
    ],
  },
  plugins: [
    [
      'expo-router',
      {
        // Note: The URL must start with "https://" in "headOrigin"
        headOrigin:
          process.env.NODE_ENV === 'development'
            ? `https://${ngrokUrl}`
            : 'https://my-website-example.com',
      },
    ],
  ],
};
```
> Do not use the development-only `?mode=developer` suffix when testing handoff to native.
After configuring the app config, regenerate your native project with the following command:
```sh
$ npx expo prebuild -p ios
```
In development, you must start the website **before installing the app on your device**. This is because when you install the app, the OS will trigger Apple's servers to ping your website for the **.well-known/apple-app-site-association** file. If the website is not running, the OS will not be able to find the file and handoff will not work. If this happens, rebuild the native app with `npx expo run:ios -d`.
## Usage
In any route that you want to support handoff, use the `Head` component from `expo-router/head`:
```tsx app/index.tsx
import Head from 'expo-router/head';
import { Text } from 'react-native';
export default function App() {
  return (
    <>
      <Head>
        <meta property="expo:handoff" content="true" />
      </Head>
      <Text>Hello World</Text>
    </>
  );
}
```
### Meta tags
The `expo-router/head` component supports the following meta tags:
| Meta tags                | Description                                                                                                                                                                                                                                           |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `expo:handoff`           | Set to `true` to enable handoff for the current route. Defaults to `false`. (iOS only)                                                                                                                                                                |
| `og:title` and `<title>` | Set the title for the `NSUserActivity` this is unused with handoff.                                                                                                                                                                                   |
| `og:description`         | Set the description for the `NSUserActivity` this is unused with handoff.                                                                                                                                                                             |
| `og:url`                 | Set the URL that should be opened when the user switches to your app. Defaults to the current URL in-app with `headOrigin` prop in the `expo-router` config plugin, as the baseURL. Passing a relative path will append the `headOrigin` to the path. |
You may want to switch the values between platforms, for that you can use **Platform.select**:
```tsx app/index.tsx
import Head from 'expo-router/head';
export default function App() {
  return (
    <Head>
      <meta
        property="og:url"
      />
    </Head>
  );
}
```
## Debugging
Ensure your Apple devices have [Handoff enabled](https://support.apple.com/en-us/HT209455). You can test this by following the steps below but substituting your app with Safari.
1. Open your native application on your device.
2. Navigate to a route in the app that supports handoff which is rendering the `<Head />` element from Expo Router.
3. To switch to your Mac and click the app's Handoff icon in the Dock.
4. To switch to your iPhone or iPad, open the App Switcher, and tap the app banner at the bottom of the screen.
If you only see the Safari icon in your iPhone's App Switcher, then handoff is not working.
## Troubleshooting
You can test the Apple App Site Association file (**public/.well-known/apple-app-site-association**) by using a validator such as, [AASA Validator](https://branch.io/resources/aasa-validator/).
If you're having issues, the best thing you can do is enable the most aggressive handoff settings in your app. This ensures that any possible route is linkable. You can do this by making sure that **public/.well-known/apple-app-site-association** file matches all routes:
```json public/.well-known/apple-app-site-association
{
  "applinks": {
    "details": [
      {
        "appIDs": ["<APPLE_TEAM_ID>.<IOS_BUNDLE_ID>"],
        "components": [
          {
            "/": "*",
            "comment": "Matches all routes"
          }
        ]
      }
    ]
  }
}
```
In the application, ensure you are not rendering the `<Head />` element conditionally (for example, in an `if/else` block), it must be rendered on every page that you want to support handoff. We recommend adding it to the [Root Layout](/router/basics/layout/#root-layout) component to ensure every route is linkable while debugging.
Ensure you can access the Ngrok URL (for example, via the browser), before installing the app on your device. If you can't access the URL, the OS will not be able to find the file and handoff will not work.
`npx expo run:ios` and Xcode will both codesign your app when associated domains is set up, this is required for handoff and universal links to work.
Handoff between your Mac and iPhone/iPad is not supported in the Expo Go app. You must build and install your app on your device.
**If you see the Safari icon in the App Switcher on your iPhone**, then it means handoff is not working.
- Ensure you are not using the `?mode=developer` suffix when testing handoff to native.
- Also be sure you're not using the local development server URL. For example, `http://localhost:8081` as this cannot be used as a valid app site association link, open the running Ngrok URL in your browser to test.
- Ensure your **public/.well-known/apple-app-site-association** file contains the `activitycontinuation` field.
- We've observed that in iOS 16.3.1 and macOS 13.0 (Ventura), bundle identifiers starting with `app.` and `io.` will sometimes not trigger the native app to show up in the iOS task switcher. Use `com.` as the first part of your bundle identifier.
Your **public/.well-known/apple-app-site-association** must be served from a secure URL (HTTPS). If you are using a development tunnel, you must use the `EXPO_TUNNEL_SUBDOMAIN` environment variable to configure the subdomain for your development tunnel. The tunnel is required for testing in development because you need SSL to use universal links, Expo CLI provides built-in support for this by running `npx expo start --tunnel`.
Check your **ios&#x2f;project&#x2f;project.entitlements** file, under the `com.apple.developer.associated-domains` key. This should contain the same domains as your web server/website. The URL cannot contain a protocol (`https://`) or additional pathname, query parameters, or fragments.
### Still stuck
> This is an important but very difficult feature to set up. Expo Router automates many of the moving parts, Expo CLI automates much of the configuration and hosting. However, hardware settings can still be misconfigured.
If all else fails, you can try to debug the issue by following the steps in the [Apple Docs](https://developer.apple.com/documentation/foundation/task_management/implementing_handoff_in_your_app). Note that:
- "Representing user activities as instances of `NSUserActivity`." is performed by the Expo Head native module.
- "Updating the activity instances as the user performs actions in your app." is performed by mounting/rendering the `<Head />` component with the meta tag `<meta property="expo:handoff" content="true" />` inside.
- "Receiving activities from Handoff in your app on other devices." is performed by an [App Delegate Subscriber](/modules/appdelegate-subscribers) in the Expo Head native module. It is used to redirect you to the correct route when you handoff to your native app.
## Known issues
Handoff from web to native does not support client-side routing. This means the URL presented in the App Switcher will be the URL of the page you were on when you clicked the link, or reloaded the page. It is a limitation of the web platform and not something that can be fixed by Expo Router.


## Custom tab layouts

Learn how to use headless tab components to create custom tab layouts in Expo Router.

> **important** Experimentally available in SDK 52 and later.
Expo Router offers a set of components to create custom tab layouts via the submodule [`expo-router/ui`](/versions/latest/sdk/router-ui/). Unlike the React Navigation styled `Tabs`, these components are unstyled and flexible. They are designed to allow you build complex UI patterns from scratch in your project.
For other tab layouts see:
## Anatomy of custom Tabs components
There are four components offered by `expo-router/ui` to create custom tab layouts:
| Component    | Description                                                                                                                     |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------- |
| `Tabs`       | Wrapper component which contains the `<View>` for the tabs.                                                                     |
| `TabList`    | The containing `<View>` for the list of `TabTrigger` components.                                                                |
| `TabTrigger` | A trigger component to switch to the specified tab. It is used to define the route using `href` prop and a `name` for each tab. |
| `TabSlot`    | A slot to render the currently selected tab.                                                                                    |
A bare minimum structure of a custom tab layout would consist of a `TabList` (containing `TabTrigger` components for each tab) and a`TabSlot`, all within the `Tabs` component, as shown here:
```tsx app/(tabs)/_layout.tsx|collapseHeight=440
import { Tabs, TabList, TabTrigger, TabSlot } from 'expo-router/ui';
import { Text } from 'react-native';
// Defining the layout of the custom tab navigator
export default function Layout() {
  return (
    <Tabs>
      <TabSlot />
      <TabList>
        <TabTrigger name="home" href="/">
          <Text>Home</Text>
        </TabTrigger>
        <TabTrigger name="article" href="/article">
          <Text>Article</Text>
        </TabTrigger>
      </TabList>
    </Tabs>
  );
}
```
## Creating routes
The `TabList` contains all the routes available within the tab navigator. It must be an immediate child of `Tabs`. Each route is defined by a `TabTrigger` within the `TabList`. A `TabTrigger` within a `TabList` must include a `name` and a `href` prop.
Typically, the `TabList` defines both the available tab routes and the appearance of the tabs, with the children of each `TabTrigger` defining the appearance of each tab button.
> **Note:** A `name` can be any `string`. This is a user-defined name for the Tab.
### Dynamic routes
Dynamic routes are allowed and can be provided with values via the `href`.
```
âââ _layout.tsx
âââ [slug].tsx
```
The trigger `<TabTrigger name="dynamic page" href="/hello-world" />` will create a tab for **[slug].tsx** with the params `{ slug: 'hello-world' }`. This setup can be useful for displaying an arbitrary number of tabs in the tab bar, based on end-user data, such as showing a separate tab for each user profile in an app.
### Ambiguous routes
```
âââ _layout.tsx
âââ (one,two)/
    âââ route.tsx  # A route within a shared group
```
The `href` values provided to `TabTrigger` must always point to a single route. In the above example of a shared route, href `/route` is not allowed, as it could refer to either `/(one)/route` or `/(two)/route`. However, specifying the route group within the href would work (for example,`href="/(one)/route"`).
### Nested routes
```
âââ _layout.tsx
âââ (stack-one)/
    âââ _layout.tsx  # A <Stack> layout
    âââ (stack-two)/
        âââ _layout.tsx  # Nested <Stack> layout
        âââ route.tsx
```
A `TabTrigger` can link to a deeply nested route. `<TabTrigger name="route" href="/route" />` will show the **(stack-one)/(stack-two)/route.tsx** route. This tab will be controlled by that route's parent navigator (that is, the navigator within **stack-two_layout.tsx**). This navigation is similar to a deep link.
## Rendering routes
The `TabSlot` component renders the current route. `TabSlot` can be nested inside other components within `Tabs` but cannot be within the `TabList`.
```tsx app/_layout.tsx
<Tabs>
  <TabList>
    <TabTrigger name="home" href="/">
      <Text>Home</Text>
    </TabTrigger>
  </TabList>
  {/* Customize how `<TabSlot />` is rendered. */}
  <View>
    <View>
      <TabSlot />
    </View>
  </View>
</Tabs>
```
## Switching tabs
Tabs can be switched via a `Link` or using the imperative APIs. However, these APIs will always perform a navigation action (they will switch tabs and might change the URL). To switch tabs without performing any navigation, you should use a `TabTrigger`. A `TabTrigger` is an unstyled `<View>` that will switch tabs when pressed, much like how text and components can be wrapped in `Link` to make them pressable navigation elements.
### Resetting navigation
The `reset` prop from `TabTrigger` can be used to control when a tab resets its navigation state. The options are `always`, `onLongPress` and `never`. This is particularly useful for a stack navigator nested inside a tab. For example, `<TabTrigger name="home" reset="always" />` will return the user to the index route inside a tab's nested stack navigator.
## TabTrigger
The `TabTrigger` is used to switch tabs, but also has a dual role of defining what routes are available as a tab.
### Within TabList
When a `TabTrigger` is used as a child of `TabList`, that defines what routes are available within the tab navigator. These `TabTrigger` need to include both the `name` and `href` props, as they define the URL for that tab and a custom name that can be used to refer to the tab. If the `TabTrigger` components also contain text or other components as children, then those will also render as the tab buttons. However, you can define the `TabTrigger`'s within the `TabList` without any UI, and they can then be invoked by `TabTrigger`'s outside of the `TabList`.
### Outside TabList
An additional `TabTrigger` can be defined outside of a `TabList`, allowing you to perform the same action as the `TabTrigger` that is defined in the `TabList`. In this case, the `TabTrigger` will not have an `href` prop. Rather, it will perform the same action as the primary `TabTrigger` with the same `name` prop. This allows you to create components that can switch tabs and be agnostic to your current navigation state. Note that all `TabTrigger`'s need to at least be descendants of the `Tabs` component, or else they will be considered to be outside the tab navigator and unable to invoke it.
## Customizing appearance
All components are rendered unstyled as a `<View>`, except `TabTrigger` which renders as a `<Pressable>`. This allows you to provide a custom `style` prop to customize their appearance. Styling `TabList` is similar to customizing the tab bar in React Navigation, while styling `TabTrigger` affects the appearance of tab buttons.
If you need to change the structure of a component, you can override its underlying component by using the `asChild` props. The component then acts as a slot, and will forward its props to its immediate child.
```tsx Custom TabList
<Tabs>
  <TabSlot />
  <TabList asChild>
    {/* Render a custom TabList */}
    <CustomTabList>
      <TabTrigger name="home" href="/">
        <Text>Home</Text>
      </TabTrigger>
    </CustomTabList>
  </TabList>
</Tabs>
```
```tsx Custom Button
<Tabs>
  <TabSlot />
  <TabList asChild>
    <TabTrigger name="home" href="/" asChild>
      {/* Render a custom button */}
      <CustomButton>
        <Text>Home</Text>
      </CustomButton>
    </TabTrigger>
  </TabList>
</Tabs>
```
### Multiple tab bars
The `TabList` is both the configuration and default appearance of the `Tabs`, but it is not the only way to render a tab bar. By hiding the `TabList`, you can construct custom tab bars using `TabTrigger`.
```tsx Multiple tab bars example
<Tabs>
  <TabSlot />
  {/* A custom tab bar */}
  <View>
    <View>
      <TabTrigger name="home">
        <Text>Home</Text>
      </TabTrigger>
      <TabTrigger name="article">
        <Text>article</Text>
      </TabTrigger>
    </View>
  </View>
  <TabList style={{ display: 'none' }}>
    <TabTrigger name="home" href="/">
      <Text>Home</Text>
    </TabTrigger>
    <TabTrigger name="article" href="/article">
      <Text>article</Text>
    </TabTrigger>
  </TabList>
</Tabs>
```
`TabTrigger` will forward an `isFocused` prop, so you can create a separate tab button component that reacts to focused status.
```tsx TabButton.tsx
import FontAwesome from '@expo/vector-icons/FontAwesome';
import { TabTriggerSlotProps } from 'expo-router/ui';
import { ComponentProps, Ref } from 'react';
import { Text, Pressable, View } from 'react-native';
type Icon = ComponentProps<typeof FontAwesome>['name'];
export type TabButtonProps = TabTriggerSlotProps & {
  icon?: Icon;
  ref: Ref<View>;
};
export function TabButton({ icon, children, isFocused, ...props }: TabButtonProps) {
  return (
    <Pressable
      {...props}
      style={[
        {
          display: 'flex',
          justifyContent: 'space-between',
          alignItems: 'center',
          flexDirection: 'column',
          gap: 5,
          padding: 10,
        },
        isFocused ? { backgroundColor: 'white' } : undefined,
      ]}>
      <FontAwesome name={icon} />
      <Text style={[{ fontSize: 16 }, isFocused ? { color: 'white' } : undefined]}>{children}</Text>
    </Pressable>
  );
}
```
Note: Expo SDK 52 / React 18 and earlier
---
In Expo SDK 52 and earlier (React 18), use the legacy `forwardRef` function to access the `ref` handle.
<DiffBlock source="/static/diffs/router/custom-tabs/forward-ref-tab-button.diff" />
---
### Hooks
All components also have a hook version giving you control over the render tree. See the [Router UI Reference](/versions/latest/sdk/router-ui/) for a full list of the hooks available.
Using hooks is considered advanced usage of this library. For most use-cases, using the components with `asChild` should give you enough control over the render tree.
If you are developing a custom `<TabTrigger />`, you may also need to develop a custom `<TabList />` as `<TabList />` uses the [`useTabsWithChildren()`](/versions/latest/sdk/router-ui/#usetabswithchildrenoptions) which requires using the exported `<TabTrigger />` component.
### Customizing how tab screens are rendered
The `TabSlot` accepts a `renderFn` property. This function can be used to override how your screen is rendered, allowing you to implement advanced functionality such as animations or persisting/unmounting screens. See the [Router UI Reference](/versions/latest/sdk/router-ui/) for more information.
## Common questions
Note: How do I create multiple tabs for the same route?
---
```
âââ _layout.tsx  # Tabs layout
âââ (movie,tv)/
    âââ [id].tsx
```
You should add the route to a shared group and create a separate `TabTrigger` for each group `group`.
---
Note: How do I hide a tab?
---
Not rendering the `TabTrigger` will remove that tab (and its navigation state) from your app.
---
Note: How do I create animated tabs?
---
You can provide a custom renderer to `TabSlot` to customize how it renders a screen. You can use this to detect when screen is focused an animate appropriately.
---
Note: Can I use relative hrefs?
---
```
âââ directory/
    âââ _layout.tsx  # The local pathname is /directory
    âââ page.tsx  # The pathname is /directory/page
    âââ profile.tsx  # The pathname is /directory/profile
```
A `TabTrigger` with a relative href is relative to the local path name `Tabs` was rendered on. This is different from normal relative hrefs which are relative to the current displayed route. For example, the `<TabTrigger href="./profile" />` will resolve to `/directory/profile`, even when the `/directory/page` route is showing. Expo recommends against using relative hrefs.
---


## Stack Toolbar

Learn how to use the iOS toolbar in Stack navigation with Expo Router.

> **important** `Stack.Toolbar` is an alpha API available on **iOS only** in **Expo SDK 55** and later. The API is subject to breaking changes.
[`Stack.Toolbar`](/versions/unversioned/sdk/router/#stacktoolbar) lets you add native iOS toolbar items to your Stack screens. You can place buttons, menus, and custom views in the header (left or right side) or in the bottom toolbar area.
## Adding header buttons
Use [`Stack.Toolbar.Button`](/versions/unversioned/sdk/router/#stacktoolbarbutton) within `Stack.Toolbar` with `placement="right"` or `placement="left"` to add buttons to the navigation header. This is useful for actions like favoriting, sharing, or editing content.
```tsx app/notes/[id].tsx
import { useState } from 'react';
import { Stack } from 'expo-router';
import { View, Text, Alert } from 'react-native';
export default function NoteScreen() {
  const [isFavorite, setIsFavorite] = useState(false);
  return (
    <>
      <Stack.Toolbar placement="right">
        <Stack.Toolbar.Button
          icon={isFavorite ? 'star.fill' : 'star'}
          onPress={() => setIsFavorite(!isFavorite)}
        />
        <Stack.Toolbar.Button icon="square.and.arrow.up" onPress={() => Alert.alert('Share')} />
      </Stack.Toolbar>
      <Stack.Toolbar placement="left">
        <Stack.Toolbar.Button icon="sidebar.left" onPress={() => Alert.alert('Sidebar')} />
      </Stack.Toolbar>
      <View style={{ flex: 1, padding: 16 }}>
        <Text>Note content...</Text>
      </View>
    </>
  );
}
```
## Icons
Toolbar buttons support two types of icons: SF Symbols and custom images.
### SF Symbols
The easiest way to add icons is using [SF Symbols](https://developer.apple.com/sf-symbols/), Apple's built-in icon library. Pass the symbol name directly to the `icon` prop:
```tsx
<Stack.Toolbar.Button icon="star.fill" onPress={() => {}} />
<Stack.Toolbar.Button icon="square.and.arrow.up" onPress={() => {}} />
<Stack.Toolbar.Menu icon="ellipsis.circle">{/* ... */}</Stack.Toolbar.Menu>
```
You can browse available symbols in Apple's SF Symbols app.
### Custom images
You can also use custom images. In header toolbars (`placement="left"` or `placement="right"`), pass an image source directly to the `icon` prop:
```tsx
import { Stack } from 'expo-router';
export default function Page() {
  return (
    <>
      <Stack.Toolbar placement="right">
        <Stack.Toolbar.Button icon={require('./assets/expo.png')} onPress={() => {}} />
      </Stack.Toolbar>
      {/* Screen content */}
    </>
  );
}
```
In the bottom toolbar, use the `useImage` hook from `expo-image` and pass the result to the `image` prop:
```tsx
import { Stack } from 'expo-router';
import { useImage } from 'expo-image';
export default function Page() {
  const customIcon = useImage('https://simpleicons.org/icons/expo.svg', {
    maxWidth: 24,
    maxHeight: 24,
  });
  return (
    <>
      <Stack.Toolbar>
        <Stack.Toolbar.Button image={customIcon} onPress={() => {}} />
      </Stack.Toolbar>
      {/* Screen content */}
    </>
  );
}
```
> **info** The `useImage` and `image` prop pattern for bottom toolbar custom images is a temporary API and may change in future releases.
## Building action menus
For screens with multiple actions, use [`Stack.Toolbar.Menu`](/versions/unversioned/sdk/router/#stacktoolbarmenu) to group them into a dropdown menu:
```tsx app/mail/[id].tsx
import { useState } from 'react';
import { Stack } from 'expo-router';
import { Alert } from 'react-native';
export default function EmailScreen() {
  const [isArchived, setIsArchived] = useState(false);
  return (
    <>
      <Stack.Toolbar placement="right">
        <Stack.Toolbar.Menu icon="ellipsis.circle">
          <Stack.Toolbar.MenuAction
            icon="arrowshape.turn.up.left"
            onPress={() => Alert.alert('Reply')}>
            Reply
          </Stack.Toolbar.MenuAction>
          <Stack.Toolbar.MenuAction
            icon="arrowshape.turn.up.right"
            onPress={() => Alert.alert('Forward')}>
            Forward
          </Stack.Toolbar.MenuAction>
          <Stack.Toolbar.MenuAction
            icon={isArchived ? 'tray.full' : 'archivebox'}
            isOn={isArchived}
            onPress={() => setIsArchived(!isArchived)}>
            {isArchived ? 'Unarchive' : 'Archive'}
          </Stack.Toolbar.MenuAction>
          <Stack.Toolbar.MenuAction icon="trash" destructive onPress={() => Alert.alert('Delete')}>
            Delete
          </Stack.Toolbar.MenuAction>
        </Stack.Toolbar.Menu>
      </Stack.Toolbar>
      {/* Email content */}
    </>
  );
}
```
The `isOn` prop on [`Stack.Toolbar.MenuAction`](/versions/unversioned/sdk/router/#stacktoolbarmenuaction) shows a checkmark next to the action, useful for toggle states. The `destructive` prop styles the action in red to indicate a dangerous operation.
### Nested submenus
For more complex menus, nest `Stack.Toolbar.Menu` inside another menu. Use the `inline` prop to display submenu items directly without collapsing:
```tsx
import { useState } from 'react';
import { Stack } from 'expo-router';
export default function EmailScreen() {
  const [sortBy, setSortBy] = useState<'name' | 'date' | 'size'>('name');
  const [showHiddenFiles, setShowHiddenFiles] = useState(false);
  return (
    <>
      <Stack.Toolbar>
        <Stack.Toolbar.Menu icon="ellipsis.circle">
          {/* Inline submenu - options appear directly in the menu */}
          <Stack.Toolbar.Menu inline title="Sort By">
            <Stack.Toolbar.MenuAction isOn={sortBy === 'name'} onPress={() => setSortBy('name')}>
              Name
            </Stack.Toolbar.MenuAction>
            <Stack.Toolbar.MenuAction isOn={sortBy === 'date'} onPress={() => setSortBy('date')}>
              Date
            </Stack.Toolbar.MenuAction>
            <Stack.Toolbar.MenuAction isOn={sortBy === 'size'} onPress={() => setSortBy('size')}>
              Size
            </Stack.Toolbar.MenuAction>
          </Stack.Toolbar.Menu>
          {/* Nested submenu - opens as a separate menu */}
          <Stack.Toolbar.Menu title="Preferences">
            <Stack.Toolbar.MenuAction
              isOn={showHiddenFiles}
              onPress={() => setShowHiddenFiles(!showHiddenFiles)}>
              Show Hidden Files
            </Stack.Toolbar.MenuAction>
          </Stack.Toolbar.Menu>
        </Stack.Toolbar.Menu>
      </Stack.Toolbar>
      {/* Email content */}
    </>
  );
}
```
## Using the bottom toolbar
iOS apps commonly have a bottom toolbar for primary actions. To add one, use `Stack.Toolbar` without a placement prop (it defaults to `"bottom"`):
```tsx app/photos/index.tsx
import { Stack } from 'expo-router';
import { Alert } from 'react-native';
export default function PhotosScreen() {
  return (
    <>
      <Stack.Toolbar>
        <Stack.Toolbar.Button icon="photo.on.rectangle" onPress={() => Alert.alert('Select')}>
          Select
        </Stack.Toolbar.Button>
        <Stack.Toolbar.Spacer />
        <Stack.Toolbar.Button icon="plus" onPress={() => Alert.alert('Add')}>
          Add
        </Stack.Toolbar.Button>
      </Stack.Toolbar>
    </>
  );
}
```
[`Stack.Toolbar.Spacer`](/versions/unversioned/sdk/router/#stacktoolbarspacer) creates flexible space between items, pushing them to opposite sides. This is how you achieve layouts like having buttons on both ends of the toolbar.
> **info** Bottom toolbars can only be used inside page components, not in layout files.
## Adding badges to buttons
In header toolbars, you can add badges to indicate counts or status. Use [`Stack.Toolbar.Icon`](/versions/unversioned/sdk/router/#stacktoolbaricon), [`Stack.Toolbar.Label`](/versions/unversioned/sdk/router/#stacktoolbarlabel), and [`Stack.Toolbar.Badge`](/versions/unversioned/sdk/router/#stacktoolbarbadge) to compose the button content:
```tsx app/inbox.tsx
import { Stack } from 'expo-router';
export default function InboxScreen() {
  const unreadCount = 5;
  return (
    <>
      <Stack.Toolbar placement="right">
        <Stack.Toolbar.Button onPress={() => {}}>
          <Stack.Toolbar.Icon sf="bell" />
          <Stack.Toolbar.Label>Notifications</Stack.Toolbar.Label>
          {unreadCount > 0 && <Stack.Toolbar.Badge>{String(unreadCount)}</Stack.Toolbar.Badge>}
        </Stack.Toolbar.Button>
      </Stack.Toolbar>
      {/* Screen content */}
    </>
  );
}
```
> **info** Badges only work in header placements (`left` or `right`), not in the bottom toolbar.
## Embedding custom views
When you need something beyond buttons and menus, use [`Stack.Toolbar.View`](/versions/unversioned/sdk/router/#stacktoolbarview) to embed any React Native component:
```tsx app/search.tsx
import { Stack } from 'expo-router';
import { Pressable, Alert } from 'react-native';
import { SymbolView } from 'expo-symbols';
export default function SearchScreen() {
  return (
    <>
      <Stack.Toolbar>
        <Stack.Toolbar.View>
          <Pressable
            style={{ width: 32, height: 32, justifyContent: 'center', alignItems: 'center' }}
            onPress={() => {
              Alert.alert('Filter pressed');
            }}>
            <SymbolView name="line.3.horizontal.decrease.circle" size={24} />
          </Pressable>
        </Stack.Toolbar.View>
      </Stack.Toolbar>
      {/* Screen content */}
    </>
  );
}
```
## Showing and hiding items dynamically
Use the `hidden` prop to toggle toolbar items based on state:
```tsx app/document.tsx
import { useState } from 'react';
import { Stack } from 'expo-router';
export default function DocumentScreen() {
  const [isEditing, setIsEditing] = useState(false);
  return (
    <>
      <Stack.Toolbar placement="right">
        <Stack.Toolbar.Button hidden={isEditing} icon="pencil" onPress={() => setIsEditing(true)} />
        <Stack.Toolbar.Button hidden={!isEditing} onPress={() => setIsEditing(false)}>
          Done
        </Stack.Toolbar.Button>
      </Stack.Toolbar>
      {/* Document content */}
    </>
  );
}
```
## Known limitations
Note: iOS only
---
`Stack.Toolbar` is only available on iOS. On Android and web, the component will not render.
---
Note: Bottom toolbar only in page components
---
The bottom toolbar can only be used inside page components, not in layout files. This is because the bottom toolbar needs to be associated with a specific screen's content.
---
Note: Cannot nest toolbars
---
You cannot nest `Stack.Toolbar` components inside each other.
---
Note: Badge only in header placements
---
`Stack.Toolbar.Badge` is only supported when using `placement="left"` or `placement="right"`. Badges are not displayed in the bottom toolbar.
---
## Learn more
For complete API documentation, including all available props, see the [`Stack.Toolbar` API reference](/versions/unversioned/sdk/router/#stacktoolbar).


## Zoom transition

Learn how to use the zoom transition to create fluid animations between screens when using Expo Router for iOS.

> **important** Zoom transition is an alpha API available on **iOS only** in **Expo SDK 55** and later. The API is subject to breaking changes.
Zoom transitions provide a fluid animation effect when navigating between screens by zooming from a source element to the destination screen. This feature leverages iOS 18+ native zoom transition API to create shared, interactive transitions that produce a sense of spatial awareness between routes. For example, a card thumbnail may transition to become a full-width banner on the next route.
## Get started
To implement zoom transitions, you need to use the `Link.AppleZoom` component to mark the source element and optionally `Link.AppleZoom.Target` to specify the target alignment on the destination screen.
### Basic example
To activate zoom transition for a link, wrap the source (`Image`) element with `Link.AppleZoom` in your screen:
```tsx app/index.tsx
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Link } from 'expo-router';
import { Image } from 'expo-image';
export default function HomeScreen() {
  return (
    <View style={styles.container}>
      <Link href="/image" asChild>
        <Link.AppleZoom>
          <Pressable>
            <Image
              source={{ uri: 'https://example.com/image-1.jpg' }}
              style={{ width: 100, height: 200 }}
            />
          </Pressable>
        </Link.AppleZoom>
      </Link>
    </View>
  );
}
```
In the destination screen, define the `Image` component:
```tsx app/image.tsx
import { View, Text, StyleSheet } from 'react-native';
import { Image } from 'expo-image';
export default function DetailsScreen() {
  return <Image source={{ uri: 'https://example.com/image-1.jpg' }} style={{ flex: 1 }} />;
}
```
## Using `Link.AppleZoom`
The `Link.AppleZoom` component wraps the element you want to zoom from. It is useful for marking the source of the zoom transition, if you want to include additional elements alongside the zoomed content.
```tsx
<Link href="/image" asChild>
  <Pressable>
    <Link.AppleZoom>
      <View>{/* Your content */}</View>
    </Link.AppleZoom>
    <Text>Subtitle</Text>
  </Pressable>
</Link>
```
> **info** `Link.AppleZoom` only accepts a single child component. If you need to wrap multiple children, use a `View` or another container component.
### Customizing alignment
You can specify the alignment of the zoomed element on the destination screen by using `Link.AppleZoom.Target` element.
```tsx app/image.tsx
export default function ImageScreen() {
  return (
    <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
      <Link.AppleZoom.Target>
        <Image source={{ uri: 'https://example.com/image-1.jpg' }} style={{ width: '100%' }} />
      </Link.AppleZoom.Target>
    </View>
  );
}
```
If you need more control over the alignment rectangle, you can pass an `alignmentRect` prop to `Link.AppleZoom`. However, this is normally not necessary if you use `Link.AppleZoom.Target`.
> **info** The `alignmentRect` prop internally relies on [`alignmentRectProvider`](https://developer.apple.com/documentation/uikit/uiviewcontroller/transition/zoomoptions/alignmentrectprovider) API.
```tsx
<Link.AppleZoom alignmentRect={{ x: 0, y: 0, width: 200, height: 300 }}>
  <Image source={{ uri: 'https://example.com/image-1.jpg' }} style={{ width: 100, height: 150 }} />
</Link.AppleZoom>
```
## Complete example
Here's a more complex example showing a gallery grid with zoom transitions to detail views. The source screen component (**app/index.tsx**) uses `Link.AppleZoom` to wrap an `Image` component:
```tsx app/index.tsx
import { Image } from 'expo-image';
import { Link } from 'expo-router';
import { useState } from 'react';
import { Text, Pressable, ScrollView, StyleSheet } from 'react-native';
const IMAGES = [
  // Define your array of images here.
];
export default function Index() {
  return (
    <ScrollView
      style={styles.scrollView}
      contentContainerStyle={styles.scrollViewContent}
      contentInsetAdjustmentBehavior="automatic">
      {IMAGES.map((_, index) => (
        <Thumbnail key={index} index={index} />
      ))}
    </ScrollView>
  );
}
function Thumbnail({ index }: { index: number }) {
  const [size, setSize] = useState<{ width: number; height: number } | null>(null);
  return (
    <Link
      href={{
        pathname: `/image/[id]`,
        // You need to pass the image size to the detail page, so that the layout can be measured during the first render.
        params: { id: index, width: size?.width, height: size?.height },
      }}
      asChild>
      <Pressable style={styles.thumbnail}>
        <Link.AppleZoom>
          <Image
            source={IMAGES[index % IMAGES.length]}
            style={styles.thumbnailImage}
            onLoad={e => setSize({ width: e.source.width, height: e.source.height })}
          />
        </Link.AppleZoom>
        <Text style={{ textAlign: 'center' }}>Photo {index + 1}</Text>
      </Pressable>
    </Link>
  );
}
const styles = StyleSheet.create({
  scrollView: {
    flex: 1,
    backgroundColor: '#fff',
    padding: 16,
  },
  scrollViewContent: {
    justifyContent: 'center',
    flexDirection: 'row',
    flexWrap: 'wrap',
    gap: 16,
  },
  thumbnail: {
    width: 170,
    aspectRatio: 1,
  },
  thumbnailImage: {
    width: '100%',
    height: '100%',
    borderRadius: 8,
  },
});
```
In the destination screen, the `Link.AppleZoomTarget` is used to specify the alignment of the zoomed element:
```tsx app/image/[id].tsx
import { Image } from 'expo-image';
import { Link, useLocalSearchParams } from 'expo-router';
import { useMemo } from 'react';
import { StyleSheet, useWindowDimensions, View } from 'react-native';
export default function ImagePage() {
  const params = useLocalSearchParams();
  const index = params.id ? parseInt(params.id as string, 10) : 0;
  const imageSource = IMAGES[index % IMAGES.length];
  const imageSize = {
    width: parseInt(params.width as string, 10),
    height: parseInt(params.height as string, 10),
  };
  const windowDimensions = useWindowDimensions();
  // Compute the size to fit within the window while maintaining aspect ratio.
  const computedSize = useMemo(() => {
    if (!imageSize.width || !imageSize.height) {
      return { width: windowDimensions.width, height: windowDimensions.height };
    }
    const widthRatio = windowDimensions.width / imageSize.width;
    const heightRatio = windowDimensions.height / imageSize.height;
    const minRatio = Math.min(widthRatio, heightRatio);
    return {
      width: imageSize.width * minRatio,
      height: imageSize.height * minRatio,
    };
  }, [imageSize, windowDimensions]);
  return (
    <View style={styles.container}>
      <Link.AppleZoomTarget>
        <View style={{ ...computedSize }}>
          <Image source={imageSource} style={styles.image} />
        </View>
      </Link.AppleZoomTarget>
    </View>
  );
}
const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    justifyContent: 'center',
    alignItems: 'center',
  },
  image: {
    width: '100%',
    height: '100%',
  },
});
```
## Controlling dismissal gestures
The [`usePreventZoomTransitionDismissal`](/versions/unversioned/sdk/router/#usepreventzoomtransitiondismissal_options) hook allows you to control the interactive swipe-to-dismiss gesture on screens using zoom transitions. This is useful when you want to prevent accidental dismissals or restrict dismissal to specific screen areas.
### Disabling dismissal completely
Call the hook without any options to completely disable the swipe-to-dismiss gesture:
```tsx app/detail.tsx
import { usePreventZoomTransitionDismissal } from 'expo-router';
export default function DetailScreen() {
  usePreventZoomTransitionDismissal();
  // Dismissal gesture is now disabled - users must use navigation controls to go back
  return <View>{/* Content */}</View>;
}
```
### Restricting dismissal to a specific area
Use the `unstable_dismissalBoundsRect` option to define a rectangle where dismissal gestures are allowed. This is useful for image viewers where you want dismissal only from the image area:
```tsx app/image.tsx
import { usePreventZoomTransitionDismissal } from 'expo-router';
import { View, StyleSheet } from 'react-native';
import { Image } from 'expo-image';
export default function DetailScreen() {
  // Only allow dismissal gestures that start within this rectangle
  usePreventZoomTransitionDismissal({
    unstable_dismissalBoundsRect: { minX: 100, minY: 100, maxX: 300, maxY: 300 },
  });
  return (
    <View style={styles.container}>
      {/* Visual indicator of the dismissal zone (for demonstration) */}
      <View style={styles.dismissalZone} />
    </View>
  );
}
const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  image: {
    flex: 1,
  },
  dismissalZone: {
    position: 'absolute',
    left: 100,
    top: 100,
    width: 200, // maxX - minX = 300 - 100
    height: 200, // maxY - minY = 300 - 100
    borderWidth: 2,
    borderColor: 'rgba(0, 122, 255, 0.5)',
    borderStyle: 'dashed',
    backgroundColor: 'rgba(0, 122, 255, 0.1)',
  },
});
```
> **info** The `unstable_dismissalBoundsRect` option internally relies on the [`interactiveDismissShouldBegin`](https://developer.apple.com/documentation/uikit/uiviewcontroller/transition/zoomoptions/interactivedismissshouldbegin) API.
## Platform support
Zoom transitions is only available on iOS 18 and later. On older iOS versions or other platforms, the component will render normally without the zoom animation effect.
The zoom transition components automatically detect platform support and gracefully degrade to standard navigation on unsupported platforms.
## Known limitations
Note: Using zoom transition with headers
---
We recommend avoiding the use of zoom transitions when navigating between screens that have a header (navigation bar). There are known issues with the native iOS zoom transition API that can lead to visual glitches or unexpected behavior when headers are involved.
---
Note: Single child requirement
---
Both `Link.AppleZoom` and `Link.AppleZoom.Target` only accept a single child component. If you attempt to pass multiple children, a warning will be logged and the component will not render properly.
**Incorrect:**
```tsx
<Link.AppleZoom>
  <View />
  <Text />
</Link.AppleZoom>
```
**Correct:**
```tsx
<Link.AppleZoom>
  <View>
    <Image />
    <Text />
  </View>
</Link.AppleZoom>
```
---
Note: Supported only within router's Stack navigator
---
The zoom transition feature is only supported when using the router's built-in Stack navigator.
If you attempt to use Link with zoom transition to a screen that is not part of a Stack navigator, the zoom transition will not work as expected.
---
Note: Must be used within a Link
---
`Link.AppleZoom` must be used as a direct or indirect child of a `Link` component with the `asChild` prop. Using it outside this context will result in an error.
---
Note: iOS 18+ only
---
The zoom transition feature requires iOS 18 or later. While the components will render on older versions, the zoom animation will not be applied.
---
Note: Using zoom transition with ScrollView
---
When using zoom transitions on screens with a `ScrollView`, the swipe-to-dismiss gesture may conflict with scrolling. To prevent accidental dismissal while scrolling, use the `usePreventZoomTransitionDismissal` hook with `unstable_dismissalBoundsRect`:
```tsx app/image.tsx
import { Link, usePreventZoomTransitionDismissal } from 'expo-router';
import { useState } from 'react';
import { ScrollView } from 'react-native';
export default function Screen() {
  const [shouldPrevent, setShouldPrevent] = useState(false);
  usePreventZoomTransitionDismissal(
    shouldPrevent
      ? undefined
      : {
          unstable_dismissalBoundsRect: { minX: 0, minY: 0 },
        }
  );
  return (
    <ScrollView
      style={{ flex: 1 }}
      onScroll={event => {
        setShouldPrevent(event.nativeEvent.contentOffset.y > 1);
      }}>
      {/* Content */}
    </ScrollView>
  );
}
```
This approach disables the swipe-to-dismiss gesture when the scroll position is at the top, allowing users to scroll down without accidentally triggering the dismissal animation. Once scrolled, the gesture is re-enabled.
---