Drawers and Custom Headers

A drawer layout, the packages it needs, replacing the header with your own component, and when to turn it off.

Syntaximport { 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

Example · tsx
// 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.

Example · tsx
// 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.

Example · tsx
// 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.

Example · tsx
// 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

  • Be the first to comment on this lesson.