# Drawer

> Drawer panel content for Expo Router's drawer (expo-router/drawer): DrawerContent, DrawerHeader, DrawerSection, DrawerItem (label, icon, href, badge; active from the current route) and DrawerFooter.

Component · navigation. Registry item `drawer`, shown in the vega Style. JSON: https://nativecn.dev/r/styles/vega/drawer.json

## Installation

```sh
npx nativecn-cli@latest add drawer
```

Also installs: [theme](https://nativecn.dev/docs/theme/theme), [pressable](https://nativecn.dev/docs/primitives/pressable), [text](https://nativecn.dev/docs/components/text), [icon](https://nativecn.dev/docs/components/icon), [badge](https://nativecn.dev/docs/components/badge).

Packages: `expo-router`, `react-native-safe-area-context`.

## Usage

### Drawer demo

An app/(drawer)/_layout.tsx: a profile header, titled sections, an accent item, a bottom group and a Log out footer.

```tsx
// app/(drawer)/_layout.tsx: a drawer Layout whose panel is built from the drawer Components.
// The drawer comes only from `expo-router/drawer` (built into Expo Router); never install
// `@react-navigation/drawer` or the unrelated npm package `expo-drawer`.
import { Drawer } from "expo-router/drawer";
import { Bell, Home, Inbox, LifeBuoy, LogOut, Settings, Users } from "lucide-react-native";
import { View } from "react-native";

import { Avatar } from "@/components/avatar";
import { Badge } from "@/components/badge";
import {
  DrawerContent,
  DrawerFooter,
  DrawerHeader,
  DrawerItem,
  DrawerSection,
} from "@/components/drawer";
import { Separator } from "@/components/separator";
import { Text } from "@/components/text";
import { createStyles } from "@/theme";

export default function DrawerLayout() {
  return (
    <Drawer
      drawerContent={(props) => (
        <DrawerContent {...props}>
          <DrawerHeader>
            <ProfileHeader />
          </DrawerHeader>

          <DrawerSection title="Main">
            <DrawerItem label="Home" icon={Home} href="/" />
            <DrawerItem label="Inbox" icon={Inbox} href="/inbox" badge={3} />
            <DrawerItem label="Team" icon={Users} href="/team" />
          </DrawerSection>

          {/* An Accent Colour highlighted item, and an item active in Accent Colour text. */}
          <DrawerSection title="Workspace">
            <DrawerItem
              label="Get started"
              href="/onboarding"
              tone="accent"
              badge={<Badge label="5 Steps" />}
            />
            <DrawerItem label="Notifications" icon={Bell} href="/notifications" variant="text" />
          </DrawerSection>

          {/* Pushed to the bottom of the scrolling area, above the footer. */}
          <DrawerSection style={{ marginTop: "auto" }}>
            <DrawerItem label="Settings" icon={Settings} href="/settings" />
            <DrawerItem label="Help" icon={LifeBuoy} href="/help" />
          </DrawerSection>

          <DrawerFooter>
            <Separator />
            <DrawerItem label="Log out" icon={LogOut} onPress={() => {}} />
          </DrawerFooter>
        </DrawerContent>
      )}
    >
      <Drawer.Screen name="index" options={{ title: "Home" }} />
    </Drawer>
  );
}

function ProfileHeader() {
  const styles = useStyles();
  return (
    <View style={styles.profile}>
      <Avatar fallback="JD" size="lg" />
      <View style={styles.name}>
        <Text variant="h4">Jane Doe</Text>
        <Text variant="small" color="mutedForeground">
          jane@example.com
        </Text>
      </View>
      <Badge label="Pro" variant="secondary" />
    </View>
  );
}

const useStyles = createStyles((t) => ({
  profile: { gap: t.spacing[3] },
  name: { gap: t.spacing[1] },
}));
```

## Props

### DrawerContent

| Prop | Type and description |
|---|---|
| `...DrawerContentComponentProps` | Expo Router's drawer props (state, navigation, descriptors): spread the drawerContent props in |
| `children` | DrawerHeader (pinned on top), DrawerSections and DrawerItems (scrolling) and DrawerFooter (pinned at the bottom), in any order |
| `contentContainerStyle` | the scrolling area's content, merged last |
| `style` | merged last onto the panel |

### DrawerHeader

| Prop | Type and description |
|---|---|
| `children` | a profile, a logo and name, or a cover |
| `style` | merged last; the top padding includes the status bar inset |

### DrawerSection

| Prop | Type and description |
|---|---|
| `title` | string: a small muted heading (role heading) |
| `children` | DrawerItems |
| `style` | merged last; { marginTop: "auto" } pushes the group to the bottom |

### DrawerItem

| Prop | Type and description |
|---|---|
| `label` | string (required): the visible text and accessible name |
| `icon` | a Lucide icon component; leave it out for text-only items |
| `href` | Href: pressing navigates there and closes the drawer; active while the pathname is this route or below it |
| `badge` | string \| number (a secondary Badge) \| ReactElement (rendered as-is) |
| `active` | boolean: overrides the active state derived from the route |
| `variant` | "default" (default): active on a muted background \| "text": active in the Accent Colour, no background |
| `tone` | "default" (default) \| "accent": an Accent Colour tint with Accent Colour text and icon |
| `onPress` | (event) => void: runs before navigating; without href the item is an action (role button) and the drawer stays open |
| `disabled` | boolean (default false) |
| `style` | layout only, merged last onto the root |

### DrawerFooter

| Prop | Type and description |
|---|---|
| `children` | Log out, a team switcher, the user |
| `style` | merged last; the bottom padding includes the home indicator inset |

## Variants

- `variant`: `default`, `text`
- `tone`: `default`, `accent`

## Accessibility

- A DrawerItem with href has role link, with aria-selected while its route is active; without href it has role button.
- An item's visible label is its accessible name.
- Items are 48 high in Vega and 40 in Nova, with the tap area extended to 48.
- DrawerSection titles have role heading.
- disabled items are announced as disabled and block presses.
- Item labels use the label text Variant, which caps the OS font scale at 1.5× when font scaling is on.

## Notes

Built only on expo-router/drawer, which ships inside Expo Router (react-native-drawer-layout): never install @react-navigation/drawer or the npm package expo-drawer. In app/(drawer)/_layout.tsx render <Drawer drawerContent={(props) => <DrawerContent {...props}>…</DrawerContent>} />. A DrawerItem with href is active while usePathname() is its route or below it (route groups such as (drawer) and index are ignored); tapping it calls router.navigate(href) and closes the drawer. Items are sized by the drawer.item Style Slot (Vega 48, Nova 40); the label, pressed look and accent tint come from drawer.label, drawer.pressed and drawer.tint. The panel keeps clear of the status bar and home indicator.

## Source

### {components}/drawer.tsx

```tsx
import { router, usePathname, type Href } from "expo-router";
import type { DrawerContentComponentProps } from "expo-router/drawer";
import type { LucideIcon } from "lucide-react-native";
import {
  Children,
  createContext,
  isValidElement,
  use,
  type ReactElement,
  type ReactNode,
  type Ref,
} from "react";
import {
  ScrollView,
  View,
  type GestureResponderEvent,
  type StyleProp,
  type ViewProps,
  type ViewStyle,
} from "react-native";
import { SafeAreaInsetsContext, initialWindowMetrics } from "react-native-safe-area-context";

import { Badge } from "@/components/badge";
import { Icon } from "@/components/icon";
import {
  Pressable,
  touchTargetHitSlop,
  type PressableHaptic,
} from "@/components/primitives/pressable";
import { Text, type TextProps } from "@/components/text";
import { createStyles, useTheme } from "@/theme";

/** How an item shows that it is the current route. */
export type DrawerItemVariant = "default" | "text";
/** `accent` tints the item with the Accent Colour whether or not it is active. */
export type DrawerItemTone = "default" | "accent";

/** The drawer's navigation, handed down by DrawerContent so items can close the drawer. */
type DrawerContextValue = { navigation?: DrawerContentComponentProps["navigation"] };
const DrawerContext = createContext<DrawerContextValue>({});

export type DrawerContentProps = Partial<DrawerContentComponentProps> &
  ViewProps & {
    /**
     * DrawerHeader (pinned to the top), DrawerSections and DrawerItems (scrolling), and
     * DrawerFooter (pinned to the bottom), in any order.
     */
    children?: ReactNode;
    /** Style for the scrolling area's content, merged after its own padding and gap. */
    contentContainerStyle?: StyleProp<ViewStyle>;
    ref?: Ref<View>;
  };

export type DrawerHeaderProps = ViewProps & { children?: ReactNode; ref?: Ref<View> };
export type DrawerFooterProps = ViewProps & { children?: ReactNode; ref?: Ref<View> };

export type DrawerSectionProps = ViewProps & {
  /** A small muted heading above the section's items (role `heading`). */
  title?: string;
  children?: ReactNode;
  ref?: Ref<View>;
};

export type DrawerSectionTitleProps = Omit<TextProps, "variant">;

export type DrawerItemProps = Omit<ViewProps, "style" | "hitSlop" | "children"> & {
  /** The visible text and the accessible name. */
  label: string;
  /** A Lucide icon at the start of the item. Leave it out for text-only items. */
  icon?: LucideIcon;
  /**
   * The route to open. Pressing navigates there and closes the drawer; the item is active
   * while the current pathname is this route or below it. Groups like `(drawer)` are ignored.
   */
  href?: Href;
  /** At the end of the item: a string or number renders a secondary Badge; any node as-is. */
  badge?: string | number | ReactElement;
  /** Overrides the active state derived from the current route. */
  active?: boolean;
  /** `default`: active on a muted background. `text`: active in the Accent Colour, no background. */
  variant?: DrawerItemVariant;
  /** `accent`: a highlighted item on an Accent Colour tint, with Accent Colour text and icon. */
  tone?: DrawerItemTone;
  /** Runs before navigating. Items without `href` are actions (role `button`) and stay open. */
  onPress?: (event: GestureResponderEvent) => void;
  onLongPress?: (event: GestureResponderEvent) => void;
  /** Blocks presses, dims the item and announces it as disabled. */
  disabled?: boolean;
  /** Haptic feedback on press (when `config.haptics` is on). */
  haptic?: PressableHaptic;
  /** Layout only, merged last onto the root. */
  style?: StyleProp<ViewStyle>;
  ref?: Ref<View>;
};

const useStyles = createStyles((t) => {
  const item = {
    height: t.scaleValue(48),
    borderRadius: t.radius.md,
    paddingHorizontal: t.spacing[3],
    gap: t.spacing[3],
  };
  const label = { ...t.type.label };
  const tint = { backgroundColor: t.colors.primary, opacity: 0.12 };
  return {
    root: { flex: 1, backgroundColor: t.colors.card },
    scroll: { flex: 1 },
    content: {
      flexGrow: 1,
      paddingHorizontal: t.spacing[3],
      paddingVertical: t.spacing[3],
      gap: t.spacing[4],
    },
    header: { paddingHorizontal: t.spacing[4], paddingBottom: t.spacing[4], gap: t.spacing[3] },
    footer: { paddingHorizontal: t.spacing[3], paddingTop: t.spacing[3], gap: t.spacing[1] },
    section: { gap: t.spacing[1] },
    sectionTitle: {
      paddingHorizontal: item.paddingHorizontal,
      paddingVertical: t.spacing[1],
    },
    item: {
      ...item,
      flexDirection: "row",
      alignItems: "center",
      borderCurve: "continuous",
      overflow: "hidden",
    },
    label: { ...label, flex: 1, minWidth: 0 },
    pressed: { backgroundColor: t.colors.muted },
    active: { backgroundColor: t.colors.muted },
    tint: {
      ...tint,
      position: "absolute",
      top: 0,
      bottom: 0,
      start: 0,
      end: 0,
      pointerEvents: "none",
    },
    disabled: { opacity: t.opacity.disabled },
    badge: { flexShrink: 0 },
  };
});

const isElementOf = (node: ReactNode, type: unknown): node is ReactElement =>
  isValidElement(node) && node.type === type;

function useInsets() {
  return use(SafeAreaInsetsContext) ?? initialWindowMetrics?.insets;
}

/**
 * The drawer panel's content: pass it Expo Router's drawer props, as in
 * `<Drawer drawerContent={(props) => <DrawerContent {...props}>…</DrawerContent>} />` from
 * `expo-router/drawer`. A DrawerHeader child is pinned to the top and a DrawerFooter to the
 * bottom, wherever they appear; everything else scrolls between them. The panel keeps clear of
 * the status bar and home indicator. `style` is merged last onto the root.
 */
export function DrawerContent({
  state: _state,
  descriptors: _descriptors,
  navigation,
  children,
  contentContainerStyle,
  style,
  ...props
}: DrawerContentProps) {
  const styles = useStyles();
  const insets = useInsets();
  const all = Children.toArray(children);
  const headers = all.filter((c) => isElementOf(c, DrawerHeader));
  const footers = all.filter((c) => isElementOf(c, DrawerFooter));
  const body = all.filter((c) => !isElementOf(c, DrawerHeader) && !isElementOf(c, DrawerFooter));

  // DrawerHeader and DrawerFooter pad the safe area themselves; without them, the scroll does.
  const safeArea: ViewStyle = {
    paddingTop: headers.length ? undefined : (insets?.top ?? 0) + styles.content.paddingVertical,
    paddingBottom: footers.length
      ? undefined
      : (insets?.bottom ?? 0) + styles.content.paddingVertical,
  };

  return (
    <DrawerContext value={{ navigation }}>
      <View style={[styles.root, style]} {...props}>
        {headers}
        <ScrollView
          style={styles.scroll}
          contentContainerStyle={[styles.content, safeArea, contentContainerStyle]}
        >
          {body}
        </ScrollView>
        {footers}
      </View>
    </DrawerContext>
  );
}

/**
 * The top of the panel (a profile, a logo and name, a cover…), pinned above the scrolling
 * items. Its top padding includes the status bar inset, so a background set with `style`
 * reaches the top edge. `style` is merged last.
 */
export function DrawerHeader({ style, ...props }: DrawerHeaderProps) {
  const styles = useStyles();
  const insets = useInsets();
  return (
    <View
      style={[
        styles.header,
        { paddingTop: (insets?.top ?? 0) + styles.header.paddingBottom },
        style,
      ]}
      {...props}
    />
  );
}

/**
 * The bottom of the panel (Log out, a team switcher, the user…), pinned below the scrolling
 * items. Its bottom padding includes the home indicator inset. `style` is merged last.
 */
export function DrawerFooter({ style, ...props }: DrawerFooterProps) {
  const styles = useStyles();
  const insets = useInsets();
  return (
    <View
      style={[
        styles.footer,
        { paddingBottom: (insets?.bottom ?? 0) + styles.footer.paddingTop },
        style,
      ]}
      {...props}
    />
  );
}

/**
 * A group of DrawerItems with an optional `title`. To push a group to the bottom of the
 * scrolling area (above the footer), pass `style={{ marginTop: "auto" }}`. `style` is merged
 * last.
 */
export function DrawerSection({ title, children, style, ...props }: DrawerSectionProps) {
  const styles = useStyles();
  return (
    <View style={[styles.section, style]} {...props}>
      {title ? <DrawerSectionTitle>{title}</DrawerSectionTitle> : null}
      {children}
    </View>
  );
}

/** A DrawerSection's heading; use it directly only for a custom section layout. */
export function DrawerSectionTitle({ style, ...props }: DrawerSectionTitleProps) {
  const styles = useStyles();
  return (
    <Text
      role="heading"
      variant="caption"
      color="mutedForeground"
      style={[styles.sectionTitle, style]}
      {...props}
    />
  );
}

/** Drop route groups like `(drawer)`, `index` and a trailing slash, and fill `[param]`s. */
function hrefPath(href: Href): string {
  const { pathname, params } =
    typeof href === "string" ? { pathname: href, params: undefined } : href;
  const value = (key: string) => params?.[key];
  const segments = pathname
    .split(/[?#]/)[0]!
    .split("/")
    .flatMap((segment) => {
      if (!segment || /^\(.+\)$/.test(segment) || segment === "index") return [];
      const rest = /^\[\.\.\.(.+)\]$/.exec(segment);
      if (rest) return [value(rest[1]!)].flat().map(String);
      const param = /^\[(.+)\]$/.exec(segment);
      return [param ? String(value(param[1]!) ?? segment) : segment];
    });
  return `/${segments.join("/")}`;
}

/** Active while the pathname is the item's route or one below it (`/` matches only itself). */
export function isDrawerItemActive(href: Href, pathname: string): boolean {
  const path = hrefPath(href);
  const current = pathname.length > 1 ? pathname.replace(/\/$/, "") : pathname;
  return current === path || (path !== "/" && current.startsWith(`${path}/`));
}

/**
 * One drawer entry: icon, label and an optional badge. With `href` it is a link to that route,
 * active (and `aria-selected`) while the current route matches; pressing it navigates and
 * closes the drawer. Without `href` it is an action button (Log out…) that runs `onPress`.
 * Height, radius and padding come from the `drawer.item` Style Slot; the tap area is extended to
 * 48. `style` is merged last onto the root.
 */
export function DrawerItem({
  label,
  icon,
  href,
  badge,
  active: activeProp,
  variant = "default",
  tone = "default",
  onPress,
  onLongPress,
  disabled = false,
  haptic,
  style,
  ...props
}: DrawerItemProps) {
  const styles = useStyles();
  const { minTouchTarget } = useTheme();
  const { navigation } = use(DrawerContext);
  const pathname = usePathname();
  const active = activeProp ?? (href != null && isDrawerItemActive(href, pathname));
  const accent = tone === "accent" || (active && variant === "text");
  const color = accent ? "primary" : "foreground";

  const handlePress = (event: GestureResponderEvent) => {
    onPress?.(event);
    if (href == null) return;
    if (!active) router.navigate(href);
    navigation?.closeDrawer();
  };

  const hitSlop = touchTargetHitSlop(undefined, styles.item.height as number, minTouchTarget);

  return (
    <Pressable
      role={href != null ? "link" : "button"}
      aria-selected={active}
      onPress={handlePress}
      onLongPress={onLongPress}
      disabled={disabled}
      haptic={haptic}
      hitSlop={hitSlop ?? 0}
      pressedStyle={styles.pressed}
      style={[
        styles.item,
        active && variant === "default" ? styles.active : null,
        disabled ? styles.disabled : null,
        style,
      ]}
      {...props}
    >
      {tone === "accent" ? <View style={styles.tint} /> : null}
      {icon ? <Icon icon={icon} color={color} /> : null}
      <Text variant="label" color={color} numberOfLines={1} style={styles.label}>
        {label}
      </Text>
      {badge == null ? null : typeof badge === "string" || typeof badge === "number" ? (
        <Badge label={String(badge)} variant="secondary" style={styles.badge} />
      ) : (
        badge
      )}
    </Pressable>
  );
}
```
