Custom tab layouts
Learn how to use headless tab components to create custom tab layouts in Expo Router.
This is an experimental feature.
Expo Router offers a set of components to create custom tab layouts via the submodule expo-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:
Native tabs — See native tabs if you want to achieve a native look and feel for your tab bar.
JavaScript tabs — See JavaScript tabs if you already use React Navigation's tabs.
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 aTabSlot, all within the Tabs component, as shown here:
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
namecan be anystring. 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.
<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.
<Tabs>
<TabSlot />
<TabList asChild>
{/* Render a custom TabList */}
<CustomTabList>
<TabTrigger name="home" href="/">
<Text>Home</Text>
</TabTrigger>
</CustomTabList>
</TabList>
</Tabs><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.
<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.
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>
);
}Hooks
All components also have a hook version giving you control over the render tree. See the Router UI Reference 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() 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 for more information.
Common questions
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.
How do I hide a tab?
Not rendering the TabTrigger will remove that tab (and its navigation state) from your app.
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 and animate appropriately.
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.
API reference
expo-router/ui is a submodule of expo-router library and exports components and hooks to build custom tab layouts, rather than using the default React Navigation navigators provided by expo-router.
Installation
To use expo-router/ui in your project, you need to install expo-router in your project. Follow the instructions from the Expo Router's installation guide:
Install Expo Router — Learn how to install Expo Router in your project.
Configuration in app config
If you are using the default template to create a new project, expo-router's config plugin is already configured in your app config.
Example app.json with config plugin
{
"expo": {
"plugins": ["expo-router"]
}
}Usage
For information about using expo-router/ui in Custom tab layouts guide:
API
import { Tabs, TabList, TabTrigger, TabSlot } from 'expo-router/ui';Components
TabContext
Type: React.Element<Context<ExpoTabsNavigatorScreenOptions>>
TabList
Type: React.Element<TabListProps>
Wrapper component for TabTriggers. TabTriggers within the TabList define the tabs.
Example
<Tabs>
<TabSlot />
<TabList>
<TabTrigger name="home" href="/" />
</TabList>
</Tabs>TabListProps
asChild
Optional • Type: boolean
Forward props to child component and removes the extra <View>. Useful for custom wrappers.
Inherited props
Tabs
Type: React.Element<TabsProps>
Root component for the headless tabs.
See:
useTabsWithChildrenfor a hook version of this component.
Example
<Tabs>
<TabSlot />
<TabList>
<TabTrigger name="home" href="/" />
</TabList>
</Tabs>TabsProps
asChild
Optional • Type: boolean
Forward props to child component and removes the extra <View>. Useful for custom wrappers.
options
Optional • Type: UseTabsOptions
Inherited props
TabSlot
Type: React.Element<TabSlotProps>
Renders the current tab.
See:
useTabSlotfor a hook version of this component.
Example
<Tabs>
<TabSlot />
<TabList>
<TabTrigger name="home" href="/" />
</TabList>
</Tabs>TabSlotProps
detachInactiveScreens
Optional • Type: boolean
Remove inactive screens.
renderFn
Optional • Type: defaultTabsSlotRender
Override how the Screen component is rendered.
Inherited props
ComponentProps<ScreenContainer>
TabTrigger
Type: React.Element<TabTriggerProps>
Creates a trigger to navigate to a tab. When used as child of TabList, its functionality slightly changes since the href prop is required, and the trigger also defines what routes are present in the Tabs.
When used outside of TabList, this component no longer requires an href.
Example
<Tabs>
<TabSlot />
<TabList>
<TabTrigger name="home" href="/" />
</TabList>
</Tabs>TabTriggerProps
asChild
Optional • Type: boolean
Forward props to child component. Useful for custom wrappers.
href
Optional • Type: Href
Name of tab. Required when used within a TabList.
name
Type: string
Name of tab. When used within a TabList this sets the name of the tab. Otherwise, this references the name.
resetOnFocus
Optional • Type: boolean
Resets the route when switching to a tab.
Inherited props
PressablePropsWithoutFunctionChildren
useTabSlot
Type: React.Element<TabSlotProps>
Returns a ReactElement of the current tab.
Example
function MyTabSlot() {
const slot = useTabSlot();
return slot;
}Hooks
useTabSlot(namedParameters)
| Parameter | Type |
|---|---|
namedParameters(optional) | TabSlotProps |
Returns a ReactElement of the current tab.
Returns: Element
Example
function MyTabSlot() {
const slot = useTabSlot();
return slot;
}useTabsWithChildren(options)
| Parameter | Type |
|---|---|
options | UseTabsWithChildrenOptions |
Hook version of Tabs. The returned NavigationContent component should be rendered. Using the hook requires using the <TabList /> and <TabTrigger /> components exported from Expo Router.
The useTabsWithTriggers() hook can be used for custom components.
Returns: { describe: (route: RouteProp<ParamListBase>, placeholder: boolean) => Descriptor<ExpoTabsNavigatorScreenOptions, Omit<NavigationHelpersCommon<ParamListBase, TabNavigationState<any>>, 'getParent'> & { } & NavigationHelpersRoute<ParamListBase, string> & EventConsumer<TabNavigationEventMap & EventMapCore<TabNavigationState<any>>> & PrivateValueStore<[ParamListBase, string, TabNavigationEventMap]> & TabActionHelpers<ParamListBase>, RouteProp<ParamListBase>>, descriptors: Record<string, Descriptor<ExpoTabsNavigatorScreenOptions, Omit<NavigationHelpersCommon<ParamListBase, TabNavigationState<any>>, 'getParent'> & { } & NavigationHelpersRoute<ParamListBase, string> & EventConsumer<TabNavigationEventMap & EventMapCore<TabNavigationState<any>>> & PrivateValueStore<[ParamListBase, string, TabNavigationEventMap]> & TabActionHelpers<ParamListBase>, RouteProp<ParamListBase>>>, navigation: { } & PrivateValueStore<[ParamListBase, unknown, unknown]> & EventEmitter<TabNavigationEventMap> & NavigationHelpersRoute<ParamListBase, string> & TabActionHelpers<ParamListBase>, NavigationContent: (__namedParameters: { children: ReactNode }) => Element, state: TabNavigationState<any> }
See:
Tabsfor the component version of this hook.
Example
export function MyTabs({ children }) {
const { NavigationContent } = useTabsWithChildren({ children })
return <NavigationContent />
}useTabsWithTriggers(options)
| Parameter | Type |
|---|---|
options | UseTabsWithTriggersOptions |
Alternative hook version of Tabs that uses explicit triggers instead of children.
Returns: { describe: (route: RouteProp<ParamListBase>, placeholder: boolean) => Descriptor<ExpoTabsNavigatorScreenOptions, Omit<NavigationHelpersCommon<ParamListBase, TabNavigationState<any>>, 'getParent'> & { } & NavigationHelpersRoute<ParamListBase, string> & EventConsumer<TabNavigationEventMap & EventMapCore<TabNavigationState<any>>> & PrivateValueStore<[ParamListBase, string, TabNavigationEventMap]> & TabActionHelpers<ParamListBase>, RouteProp<ParamListBase>>, descriptors: Record<string, Descriptor<ExpoTabsNavigatorScreenOptions, Omit<NavigationHelpersCommon<ParamListBase, TabNavigationState<any>>, 'getParent'> & { } & NavigationHelpersRoute<ParamListBase, string> & EventConsumer<TabNavigationEventMap & EventMapCore<TabNavigationState<any>>> & PrivateValueStore<[ParamListBase, string, TabNavigationEventMap]> & TabActionHelpers<ParamListBase>, RouteProp<ParamListBase>>>, navigation: { } & PrivateValueStore<[ParamListBase, unknown, unknown]> & EventEmitter<TabNavigationEventMap> & NavigationHelpersRoute<ParamListBase, string> & TabActionHelpers<ParamListBase>, NavigationContent: (__namedParameters: { children: ReactNode }) => Element, state: TabNavigationState<any> }
See:
Tabsfor the component version of this hook.
Example
export function MyTabs({ children }) {
const { NavigationContent } = useTabsWithChildren({ triggers: [] })
return <NavigationContent />
}useTabTrigger(options)
| Parameter | Type |
|---|---|
options | TabTriggerProps |
Utility hook creating custom TabTrigger.
Returns: UseTabTriggerResult
Types
ExpoTabsNavigationProp
Type: NavigationProp<ParamList, RouteName, NavigatorID, TabNavigationState<ParamListBase>, ExpoTabsScreenOptions, TabNavigationEventMap>
ExpoTabsNavigatorOptions
Literal type: union
Acceptable values are: DefaultNavigatorOptions<ParamListBase, string | undefined, TabNavigationState<ParamListBase>, ExpoTabsScreenOptions, TabNavigationEventMap, ExpoTabsNavigationProp<ParamListBase>> | Omit<TabRouterOptions, 'initialRouteName'> | ExpoTabsNavigatorScreenOptions
ExpoTabsNavigatorScreenOptions
| Property | Type | Description |
|---|---|---|
| detachInactiveScreens(optional) | boolean | - |
| freezeOnBlur(optional) | boolean | - |
| lazy(optional) | boolean | - |
| unmountOnBlur(optional) | boolean | - |
ExpoTabsScreenOptions
Type: Pick<BottomTabNavigationOptions, 'title' | 'lazy' | 'freezeOnBlur'> extended by:
| Property | Type | Description |
|---|---|---|
| action | NavigationAction | - |
| params(optional) | object | - |
| title | string | - |
SwitchToOptions
Options for switchTab function.
| Property | Type | Description |
|---|---|---|
| resetOnFocus(optional) | boolean | Navigate and reset the history on route focus. |
TabNavigationEventMap
| Property | Type | Description |
|---|---|---|
| tabLongPress | { data: undefined } | Event which fires on long press on the tab in the tab bar. |
| tabPress | { canPreventDefault: true, data: undefined } | Event which fires on tapping on the tab in the tab bar. |
TabsContextValue
Type: ReturnType<useNavigationBuilder>
The React Navigation custom navigator.
See:
useNavigationBuilderhook from React Navigation for more information.
TabsSlotRenderOptions
Options provided to the UseTabSlotOptions.
| Property | Type | Description |
|---|---|---|
| detachInactiveScreens | boolean | Should the screen be unloaded when inactive. |
| index | number | Index of screen. |
| isFocused | boolean | Whether the screen is focused. |
| loaded | boolean | Whether the screen has been loaded. |
TabTriggerOptions
| Property | Type | Description |
|---|---|---|
| href | Href | - |
| name | string | - |
Trigger
Type: extended by:
| Property | Type | Description |
|---|---|---|
| isFocused | boolean | - |
| resolvedHref | string | - |
| route | [number] | - |
UseTabsOptions
Options to provide to the Tab Router.
Type: Omit<DefaultNavigatorOptions<ParamListBase, any, TabNavigationState<any>, ExpoTabsScreenOptions, TabNavigationEventMap, any>, 'children'> extended by:
| Property | Type | Description |
|---|---|---|
| backBehavior(optional) | TabRouterOptions[backBehavior] | - |
UseTabsWithChildrenOptions
Type: PropsWithChildren<UseTabsOptions>
UseTabsWithTriggersOptions
Type: UseTabsOptions extended by:
| Property | Type | Description |
|---|---|---|
| triggers | ScreenTrigger[] | - |
UseTabTriggerResult
| Property | Type | Description |
|---|---|---|
| getTrigger | (name: string) => Trigger | undefined | - |
| switchTab | (name: string, options: SwitchToOptions) => void | - |
| trigger(optional) | Trigger | - |
| triggerProps | TriggerProps | - |
Sources
The original Expo docs for this page:
- Custom tab layouts (
source-of-truth/router/advanced/custom-tabs.md) - Router UI (
source-of-truth/versions/v57.0.0/sdk/router/ui.md)