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.
Light
Dark
Your app is ready.
Inter body text, Inter headings.
a0: Vega, neutral, neutral accent, default radius, Inter.Change PresetInstallation
npx nativecn-cli@latest add drawerAlso installs: theme, pressable, text, icon, 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.
// 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] },
}));Drawer demo
An app/(drawer)/_layout.tsx: a profile header, titled sections, an accent item, a bottom group and a Log out footer.
// 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,texttone: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
The Vega Style's code, exactly as add copies it into your app.
{components}/drawer.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>
);
}{components}/drawer.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(40),
borderRadius: t.radius.sm,
paddingHorizontal: t.scaleValue(10),
gap: t.scaleValue(10),
};
const label = {
...t.type.label,
fontSize: t.scaleValue(13),
lineHeight: t.scaleValue(18),
};
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>
);
}
