Side Drawer
A navigation drawer that springs in from the side as its rows follow one by one; drag it back to close.
import { useContext, useEffect, useState, type ReactNode } from 'react';
import {
Modal,
Pressable,
StyleSheet,
Text,
View,
useWindowDimensions,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler';
import Animated, {
interpolate,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
type SharedValue,
} from 'react-native-reanimated';
import { SafeAreaInsetsContext } from 'react-native-safe-area-context';
import { scheduleOnRN } from 'react-native-worklets';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { durations, easings, spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type DrawerItem = {
key: string;
label: string;
/** A small count or tag on the right, e.g. "12". */
badge?: string;
};
export type SideDrawerProps = {
visible: boolean;
/** Called for a tap outside, a drag back to the edge, Back and the escape gesture. */
onClose: () => void;
/** Navigation rows. They slide in one after another as the drawer opens. */
items?: readonly DrawerItem[];
/** The key of the current row, which gets a highlight. */
active?: string;
onSelect?: (key: string) => void;
/** Content above the rows, e.g. the account. */
header?: ReactNode;
/** Content pinned to the bottom, e.g. settings and sign out. */
footer?: ReactNode;
/** Which edge it comes from. Defaults to "left". */
side?: 'left' | 'right';
/** Defaults to 85% of the screen, at most 320. */
width?: number;
/** A tick when a row is chosen. Defaults to true. */
haptics?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const ROW = 48;
/**
* A navigation drawer from the side of the screen. The panel springs in over
* a dimmed screen, its rows follow one after another, and a highlight sits
* behind the current row. Drag it back towards its edge to close it.
*/
export function SideDrawer({
visible,
onClose,
items = [],
active,
onSelect,
header,
footer,
side = 'left',
width,
haptics = true,
testID,
style,
}: SideDrawerProps) {
const { colors, radius, shadow, space } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const window = useWindowDimensions();
const insets = useContext(SafeAreaInsetsContext);
const w = width ?? Math.min(320, window.width * 0.85);
const dir = side === 'left' ? -1 : 1;
const [mounted, setMounted] = useState(visible);
if (visible && !mounted) setMounted(true);
const t = useSharedValue(0);
const drag = useSharedValue(0);
useEffect(() => {
if (visible) {
drag.set(0);
t.set(reduced ? 1 : withSpring(1, spring('smooth')));
return;
}
if (reduced) {
t.set(0);
setTimeout(() => setMounted(false), 0);
return;
}
t.set(
withTiming(0, { duration: durations.base, easing: easings.exit }, (done) => {
if (done) scheduleOnRN(setMounted, false);
}),
);
}, [visible, reduced, t, drag]);
const pan = Gesture.Pan()
.withTestId('side-drawer')
.activeOffsetX([-8, 8])
.failOffsetY([-16, 16])
.onUpdate((e) => {
// Towards the edge it follows the finger; the other way it resists.
const toward = e.translationX * dir;
drag.set(toward > 0 ? toward : -Math.sqrt(-toward) * 2);
})
.onEnd((e) => {
if (drag.get() > w * 0.3 || e.velocityX * dir > 800) scheduleOnRN(onClose);
else drag.set(withSpring(0, spring('bouncy', reduced)));
});
const scrim = useAnimatedStyle(() => ({
opacity: t.get() * (1 - Math.min(1, Math.max(0, drag.get()) / w)),
}));
const panel = useAnimatedStyle(() => ({
transform: [
{ translateX: dir * (interpolate(t.get(), [0, 1], [w + 24, 0]) + Math.max(-12, drag.get())) },
],
}));
return (
<Modal
visible={mounted}
transparent
animationType="none"
onRequestClose={onClose}
statusBarTranslucent
navigationBarTranslucent
>
{/* A modal is its own window on Android, so gestures need their own root. */}
<GestureHandlerRootView style={styles.fill}>
<View style={StyleSheet.absoluteFill} onAccessibilityEscape={onClose}>
<Animated.View
style={[StyleSheet.absoluteFill, { backgroundColor: colors.scrim }, scrim]}
>
<Pressable
style={StyleSheet.absoluteFill}
onPress={onClose}
accessibilityRole="button"
accessibilityLabel="Close menu"
/>
</Animated.View>
<GestureDetector gesture={pan}>
<Animated.View
collapsable={false}
testID={testID}
style={[
styles.panel,
side === 'left' ? styles.left : styles.right,
{
width: w,
backgroundColor: colors.surface,
borderColor: colors.border,
boxShadow: shadow.lg,
paddingTop: (insets?.top ?? 0) + space[4],
paddingBottom: (insets?.bottom ?? 0) + space[4],
borderTopRightRadius: side === 'left' ? radius.xl : 0,
borderBottomRightRadius: side === 'left' ? radius.xl : 0,
borderTopLeftRadius: side === 'right' ? radius.xl : 0,
borderBottomLeftRadius: side === 'right' ? radius.xl : 0,
},
style,
panel,
]}
>
{header ? (
<View style={{ paddingHorizontal: space[4], marginBottom: space[4] }}>
{header}
</View>
) : null}
<View style={[styles.rows, { paddingHorizontal: space[2] }]} accessibilityRole="menu">
{items.map((item, i) => (
<Row
key={item.key}
item={item}
index={i}
t={t}
current={item.key === active}
onPress={() => {
haptic('tick');
onSelect?.(item.key);
}}
/>
))}
</View>
{footer ? <View style={{ paddingHorizontal: space[4] }}>{footer}</View> : null}
</Animated.View>
</GestureDetector>
</View>
</GestureHandlerRootView>
</Modal>
);
}
type RowProps = {
item: DrawerItem;
index: number;
t: SharedValue<number>;
current: boolean;
onPress: () => void;
};
function Row({ item, index, t, current, onPress }: RowProps) {
const { colors, font, radius } = useKinetikTheme();
const motion = useAnimatedStyle(() => {
const start = Math.min(0.45, 0.2 + index * 0.05);
const p = interpolate(t.get(), [start, start + 0.5], [0, 1], 'clamp');
return { opacity: p, transform: [{ translateX: (1 - p) * -16 }] };
});
return (
<Animated.View style={motion}>
<Pressable
onPress={onPress}
accessibilityRole="menuitem"
accessibilityLabel={item.badge ? `${item.label}, ${item.badge}` : item.label}
accessibilityState={{ selected: current }}
style={({ pressed }) => [
styles.row,
{
borderRadius: radius.md,
backgroundColor: current
? colors.azureSoft
: pressed
? colors.surfaceSunken
: undefined,
},
]}
>
<View style={[styles.mark, { backgroundColor: current ? colors.azure : 'transparent' }]} />
<Text
numberOfLines={1}
style={[
styles.label,
{
color: current ? colors.azure : colors.text,
fontSize: font.size.md,
fontWeight: current ? font.weight.semibold : font.weight.regular,
},
]}
>
{item.label}
</Text>
{item.badge ? (
<Text
style={{
color: colors.textSubtle,
fontSize: font.size.sm,
fontVariant: ['tabular-nums'],
}}
>
{item.badge}
</Text>
) : null}
</Pressable>
</Animated.View>
);
}
const styles = StyleSheet.create({
fill: { flex: 1 },
panel: { position: 'absolute', top: 0, bottom: 0, borderWidth: StyleSheet.hairlineWidth },
left: { left: 0 },
right: { right: 0 },
rows: { flex: 1, gap: 2 },
row: { height: ROW, flexDirection: 'row', alignItems: 'center', gap: 12, paddingRight: 14 },
mark: { width: 3, height: 18, borderRadius: 2 },
label: { flex: 1 },
});Installation
npx kinetik-ui add side-drawerpnpm dlx kinetik-ui add side-drawerbunx kinetik-ui add side-draweryarn dlx kinetik-ui add side-drawerThe CLI copies the source and installs native dependencies with npx expo install, so versions match your Expo SDK. With shadcn: npx shadcn@latest add https://kinetik-ui.dev/r/side-drawer.json
Install manually
- Install dependencies
npx expo install react-native-gesture-handler react-native-reanimated react-native-safe-area-context react-native-worklets - Copy the source files
components/kinetik/side-drawer.tsx
Usage
import { SideDrawer } from '@/components/kinetik/side-drawer';
<SideDrawer
visible={open}
onClose={() => setOpen(false)}
items={[
{ key: 'inbox', label: 'Inbox', badge: '12' },
{ key: 'sent', label: 'Sent' },
]}
active={section}
onSelect={(key) => {
setSection(key);
setOpen(false);
}}
header={<Account />}
/>;When to use
- Apps with more sections than fit in a tab bar: mail folders, workspaces, admin areas.
- Secondary navigation that people open now and then, not on every screen.
When not to use
- Three to five main sections used all the time. Use a tab bar (Dock Bar or Liquid Tabs).
- A list of actions on one object. Use Action Sheet.
Notes
itemsrender as rows that slide in one by one;headerandfootertake any content.- Drag the panel towards its edge past about a third of its width, or flick it, to close; the other way it resists.
- Top and bottom padding follow the safe area when the app has a
SafeAreaProvider(Expo Router adds one). - With reduced motion it appears and disappears without sliding.
Props
SideDrawerProps
| Prop | Type | Default | Description |
|---|---|---|---|
visiblerequired | boolean | — | |
onCloserequired | () => void | — | Called for a tap outside, a drag back to the edge, Back and the escape gesture. |
items | readonly DrawerItem[] | [] | Navigation rows. They slide in one after another as the drawer opens. |
active | string | — | The key of the current row, which gets a highlight. |
onSelect | (key: string) => void | — | |
header | ReactNode | — | Content above the rows, e.g. the account. |
footer | ReactNode | — | Content pinned to the bottom, e.g. settings and sign out. |
side | 'left' | 'right' | 'left' | Which edge it comes from. Defaults to "left". |
width | number | — | Defaults to 85% of the screen, at most 320. |
haptics | boolean | true | A tick when a row is chosen. Defaults to true. |
testID | string | — | |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- A transparent modal of plain views; the panel, scrim and rows read two shared values. Nothing runs at rest.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 1.11× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 147 MB app memory. p99 spikes are the first frame of the modal window opening.
Real-device measurement pending.
Accessibility
- Reduced motion
- The drawer appears and disappears without sliding, rows included.
- Screen readers
- Rows are menu items with a selected state; outside the panel is a "Close menu" button, and Back closes it.
- Touch target
- Rows are 48 pt tall and nearly the full width of the panel.
Try it on a device
Scan with a phone that has the Kinetik playground installed to open this demo with real haptics and sensors.
Source files
components/kinetik/side-drawer.tsx
import { useContext, useEffect, useState, type ReactNode } from 'react';
import {
Modal,
Pressable,
StyleSheet,
Text,
View,
useWindowDimensions,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler';
import Animated, {
interpolate,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
type SharedValue,
} from 'react-native-reanimated';
import { SafeAreaInsetsContext } from 'react-native-safe-area-context';
import { scheduleOnRN } from 'react-native-worklets';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { durations, easings, spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type DrawerItem = {
key: string;
label: string;
/** A small count or tag on the right, e.g. "12". */
badge?: string;
};
export type SideDrawerProps = {
visible: boolean;
/** Called for a tap outside, a drag back to the edge, Back and the escape gesture. */
onClose: () => void;
/** Navigation rows. They slide in one after another as the drawer opens. */
items?: readonly DrawerItem[];
/** The key of the current row, which gets a highlight. */
active?: string;
onSelect?: (key: string) => void;
/** Content above the rows, e.g. the account. */
header?: ReactNode;
/** Content pinned to the bottom, e.g. settings and sign out. */
footer?: ReactNode;
/** Which edge it comes from. Defaults to "left". */
side?: 'left' | 'right';
/** Defaults to 85% of the screen, at most 320. */
width?: number;
/** A tick when a row is chosen. Defaults to true. */
haptics?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const ROW = 48;
/**
* A navigation drawer from the side of the screen. The panel springs in over
* a dimmed screen, its rows follow one after another, and a highlight sits
* behind the current row. Drag it back towards its edge to close it.
*/
export function SideDrawer({
visible,
onClose,
items = [],
active,
onSelect,
header,
footer,
side = 'left',
width,
haptics = true,
testID,
style,
}: SideDrawerProps) {
const { colors, radius, shadow, space } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const window = useWindowDimensions();
const insets = useContext(SafeAreaInsetsContext);
const w = width ?? Math.min(320, window.width * 0.85);
const dir = side === 'left' ? -1 : 1;
const [mounted, setMounted] = useState(visible);
if (visible && !mounted) setMounted(true);
const t = useSharedValue(0);
const drag = useSharedValue(0);
useEffect(() => {
if (visible) {
drag.set(0);
t.set(reduced ? 1 : withSpring(1, spring('smooth')));
return;
}
if (reduced) {
t.set(0);
setTimeout(() => setMounted(false), 0);
return;
}
t.set(
withTiming(0, { duration: durations.base, easing: easings.exit }, (done) => {
if (done) scheduleOnRN(setMounted, false);
}),
);
}, [visible, reduced, t, drag]);
const pan = Gesture.Pan()
.withTestId('side-drawer')
.activeOffsetX([-8, 8])
.failOffsetY([-16, 16])
.onUpdate((e) => {
// Towards the edge it follows the finger; the other way it resists.
const toward = e.translationX * dir;
drag.set(toward > 0 ? toward : -Math.sqrt(-toward) * 2);
})
.onEnd((e) => {
if (drag.get() > w * 0.3 || e.velocityX * dir > 800) scheduleOnRN(onClose);
else drag.set(withSpring(0, spring('bouncy', reduced)));
});
const scrim = useAnimatedStyle(() => ({
opacity: t.get() * (1 - Math.min(1, Math.max(0, drag.get()) / w)),
}));
const panel = useAnimatedStyle(() => ({
transform: [
{ translateX: dir * (interpolate(t.get(), [0, 1], [w + 24, 0]) + Math.max(-12, drag.get())) },
],
}));
return (
<Modal
visible={mounted}
transparent
animationType="none"
onRequestClose={onClose}
statusBarTranslucent
navigationBarTranslucent
>
{/* A modal is its own window on Android, so gestures need their own root. */}
<GestureHandlerRootView style={styles.fill}>
<View style={StyleSheet.absoluteFill} onAccessibilityEscape={onClose}>
<Animated.View
style={[StyleSheet.absoluteFill, { backgroundColor: colors.scrim }, scrim]}
>
<Pressable
style={StyleSheet.absoluteFill}
onPress={onClose}
accessibilityRole="button"
accessibilityLabel="Close menu"
/>
</Animated.View>
<GestureDetector gesture={pan}>
<Animated.View
collapsable={false}
testID={testID}
style={[
styles.panel,
side === 'left' ? styles.left : styles.right,
{
width: w,
backgroundColor: colors.surface,
borderColor: colors.border,
boxShadow: shadow.lg,
paddingTop: (insets?.top ?? 0) + space[4],
paddingBottom: (insets?.bottom ?? 0) + space[4],
borderTopRightRadius: side === 'left' ? radius.xl : 0,
borderBottomRightRadius: side === 'left' ? radius.xl : 0,
borderTopLeftRadius: side === 'right' ? radius.xl : 0,
borderBottomLeftRadius: side === 'right' ? radius.xl : 0,
},
style,
panel,
]}
>
{header ? (
<View style={{ paddingHorizontal: space[4], marginBottom: space[4] }}>
{header}
</View>
) : null}
<View style={[styles.rows, { paddingHorizontal: space[2] }]} accessibilityRole="menu">
{items.map((item, i) => (
<Row
key={item.key}
item={item}
index={i}
t={t}
current={item.key === active}
onPress={() => {
haptic('tick');
onSelect?.(item.key);
}}
/>
))}
</View>
{footer ? <View style={{ paddingHorizontal: space[4] }}>{footer}</View> : null}
</Animated.View>
</GestureDetector>
</View>
</GestureHandlerRootView>
</Modal>
);
}
type RowProps = {
item: DrawerItem;
index: number;
t: SharedValue<number>;
current: boolean;
onPress: () => void;
};
function Row({ item, index, t, current, onPress }: RowProps) {
const { colors, font, radius } = useKinetikTheme();
const motion = useAnimatedStyle(() => {
const start = Math.min(0.45, 0.2 + index * 0.05);
const p = interpolate(t.get(), [start, start + 0.5], [0, 1], 'clamp');
return { opacity: p, transform: [{ translateX: (1 - p) * -16 }] };
});
return (
<Animated.View style={motion}>
<Pressable
onPress={onPress}
accessibilityRole="menuitem"
accessibilityLabel={item.badge ? `${item.label}, ${item.badge}` : item.label}
accessibilityState={{ selected: current }}
style={({ pressed }) => [
styles.row,
{
borderRadius: radius.md,
backgroundColor: current
? colors.azureSoft
: pressed
? colors.surfaceSunken
: undefined,
},
]}
>
<View style={[styles.mark, { backgroundColor: current ? colors.azure : 'transparent' }]} />
<Text
numberOfLines={1}
style={[
styles.label,
{
color: current ? colors.azure : colors.text,
fontSize: font.size.md,
fontWeight: current ? font.weight.semibold : font.weight.regular,
},
]}
>
{item.label}
</Text>
{item.badge ? (
<Text
style={{
color: colors.textSubtle,
fontSize: font.size.sm,
fontVariant: ['tabular-nums'],
}}
>
{item.badge}
</Text>
) : null}
</Pressable>
</Animated.View>
);
}
const styles = StyleSheet.create({
fill: { flex: 1 },
panel: { position: 'absolute', top: 0, bottom: 0, borderWidth: StyleSheet.hairlineWidth },
left: { left: 0 },
right: { right: 0 },
rows: { flex: 1, gap: 2 },
row: { height: ROW, flexDirection: 'row', alignItems: 'center', gap: 12, paddingRight: 14 },
mark: { width: 3, height: 18, borderRadius: 2 },
label: { flex: 1 },
});