Drawers and Custom Headers
A drawer layout, the packages it needs, replacing the header with your own component, and when to turn it off.
import { Drawer } from 'expo-router/drawer'A drawer is a panel that slides in from the edge of the screen, holding sections that do not earn a place in a tab bar. Expo Router exposes it as a layout: import Drawer from expo-router/drawer and export it from a _layout.tsx like any other navigator.
It depends on native gesture and animation code, so install the peers first: npx expo install react-native-reanimated react-native-worklets react-native-gesture-handler. From SDK 56 onwards, which includes SDK 57, the drawer itself ships inside expo-router; on SDK 54 and 55 you also install @react-navigation/drawer.
Screens and labels
drawerLabel is the text in the panel and title is the text in the header; they are separate on purpose, because a short menu entry and a full screen title are rarely the same words. drawerIcon mirrors tabBarIcon. drawerType chooses how the panel moves, and permanent keeps it open on a wide screen. drawerPosition defaults to left, and to right in a right-to-left locale.
Your own header
The header option replaces the navigator's header with a component of yours. It is called with navigation, route and options, which is where navigation.toggleDrawer() comes from. Where a screen draws its own title bar instead, set headerShown: false — and then handle the status bar inset yourself, because the header was the thing adding it.
Example
// app/_layout.tsx
// On screen: a header with a hamburger button; tapping it or swiping from the
// left edge slides a panel in listing Overview, Archive and Report.
//
// Install the peers first:
// npx expo install react-native-reanimated react-native-worklets react-native-gesture-handler
// From SDK 56 the drawer ships inside expo-router; SDK 54 and 55 also need
// @react-navigation/drawer.
import Ionicons from '@expo/vector-icons/Ionicons';
import { Drawer } from 'expo-router/drawer';
export default function RootLayout() {
return (
<Drawer screenOptions={{ drawerActiveTintColor: '#0a7ea4' }}>
<Drawer.Screen
name="index"
options={{
drawerLabel: 'Overview', // the text inside the panel
title: 'This month', // the text in the header
drawerIcon: ({ color, size }) => <Ionicons name="home" size={size} color={color} />,
}}
/>
<Drawer.Screen
name="archive"
options={{
drawerLabel: 'Archive',
title: 'Archive',
drawerIcon: ({ color, size }) => <Ionicons name="albums" size={size} color={color} />,
}}
/>
<Drawer.Screen
name="report"
options={{
drawerLabel: 'Report',
// This screen paints its own title row, so switch the header off.
headerShown: false,
}}
/>
</Drawer>
);
}When to use it
- Giving an app more top-level sections than a tab bar can hold.
- Replacing the stock header with a branded one that has a logo and a search field.
- Letting a map or photo screen draw edge to edge while the rest of the app keeps a header.
More examples
A header component of your own
Use this when the design calls for a header the built-in options cannot express: a logo, a search field, two rows. The toggle button has to be supplied by you, because replacing the header also removes the hamburger it provided.
// app/_layout.tsx
import Ionicons from '@expo/vector-icons/Ionicons';
import { Drawer } from 'expo-router/drawer';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
type AppHeaderProps = {
title: string;
onMenu: () => void;
};
// Props destructured in the parameter list. The long form is
// (props: AppHeaderProps) => ... props.title, and it means the same thing.
function AppHeader({ title, onMenu }: AppHeaderProps) {
const insets = useSafeAreaInsets();
// paddingTop keeps the row clear of the status bar. A custom header gets no
// inset for free - the navigator's own header was adding it.
return (
<View style={[styles.header, { paddingTop: insets.top + 8 }]}>
<Pressable onPress={onMenu} hitSlop={12}>
<Ionicons name="menu" size={24} color="#ffffff" />
</Pressable>
<Text style={styles.title}>{title}</Text>
</View>
);
}
export default function RootLayout() {
return (
<Drawer
screenOptions={{
header: ({ navigation, options }) => (
<AppHeader title={options.title ?? ''} onMenu={() => navigation.toggleDrawer()} />
),
}}
>
<Drawer.Screen name="index" options={{ title: 'This month' }} />
<Drawer.Screen name="archive" options={{ title: 'Archive' }} />
</Drawer>
);
}
const styles = StyleSheet.create({
header: {
flexDirection: 'row', // flexDirection defaults to column here, not row
alignItems: 'center',
gap: 16,
paddingHorizontal: 16,
paddingBottom: 12,
backgroundColor: '#0a7ea4',
},
title: { color: '#ffffff', fontSize: 18, fontWeight: '600' },
});A screen with its own title bar
The pairing for headerShown: false. Any screen that draws edge to edge - a map, a photo viewer, a large-title list - needs this safe-area handling, or its first element is partly hidden on most phones.
// app/report.tsx
// headerShown is false for this route, so the screen owns the top of the
// display, including the inset the header used to add.
import { StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
export default function Report() {
return (
// edges picks which insets to apply. Without 'top' the title sits under
// the clock on every device with a notch or an island.
<SafeAreaView edges={['top']} style={styles.safe}>
<View style={styles.titleRow}>
<Text style={styles.title}>Report</Text>
</View>
<View style={styles.body}>
<Text>Nothing to report this month.</Text>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: '#ffffff' },
titleRow: { paddingHorizontal: 16, paddingVertical: 12 },
title: { fontSize: 28, fontWeight: '700' },
body: { flex: 1, padding: 16 },
});Drawer behaviour and a hidden entry
drawerType 'permanent' is the one-line way to make a phone drawer behave sensibly on a tablet. The hidden entry matters because every file in the drawer's folder becomes a menu item by default, including screens only reached from somewhere else.
// app/_layout.tsx - how the panel moves, and keeping a route out of the list
import { Drawer } from 'expo-router/drawer';
export default function RootLayout() {
return (
<Drawer
screenOptions={{
drawerType: 'front', // slides over the screen; 'permanent' stays open on a tablet
drawerPosition: 'left', // defaults to left, and to right in an RTL locale
swipeEdgeWidth: 40, // how far from the edge the opening gesture starts
}}
>
<Drawer.Screen name="index" options={{ drawerLabel: 'Overview', title: 'This month' }} />
{/* A route that must stay linkable but out of the menu */}
<Drawer.Screen
name="legal"
options={{ title: 'Legal', drawerItemStyle: { display: 'none' } }}
/>
</Drawer>
);
}
Discussion