About this animation
A side drawer that pushes content into a perspective stack, with configurable destinations, screen content, accent color, and fonts.
Why this one
The screen moves aside instead of disappearing under a drawer. A smaller, darker copy adds a second layer of depth, while a spring-driven indicator connects the menu destinations. The Nimbus demo pairs an indigo workspace with a light task panel and a real calendar destination.
How it works
onLayoutmeasures the container. One continuous progress value drives the screen's horizontal displacement, 0.86 scale, −12° Y rotation, 28-point corners, background visibility, and darker stack copy.- An edge pan cancels the current spring and continues from its current position. Release projects velocity to choose open or closed and carries that velocity into a damped spring. Tap the menu button to open, or the displaced screen to close.
- Menu rows derive their staggered entrance, opacity, and three-point blur from that same progress. The selection indicator has a separate continuous spring position. Native iOS uses the opacity and offset entrance where the platform does not support view blur filters.
- Selecting a destination crossfades old and new content over 200 ms while closing. React updates occur only at selection, layout, and navigation state changes; animation frames stay on the UI thread.
- The demo opens at 450 ms, highlights Calendar at 1,750 ms, and selects it at 2,650 ms. A second opening returns to Inbox. Any touch or mouse press cancels the loop.
Install
npx expo install expo-image expo-linear-gradient react-native-gesture-handler react-native-reanimated react-native-workletsWrap your application in GestureHandlerRootView. Configure Reanimated/Worklets for your Expo SDK. No Skia initialization or additional native module is required.
Usage
import DepthSideDrawer from './depth-side-drawer';
import { Text, View } from 'react-native';
export function Workspace() {
return (
<DepthSideDrawer
renderContent={(item) => (
<View style={{ flex: 1, padding: 24 }}>
<Text>{item.label}</Text>
</View>
)}
/>
);
}Customize content and motion, or control navigation through the ref:
import { useRef } from 'react';
import DepthSideDrawer, { type DepthSideDrawerRef } from './depth-side-drawer';
export function Workspace() {
const drawer = useRef<DepthSideDrawerRef>(null);
return (
<DepthSideDrawer
ref={drawer}
brandName="Workspace"
workspaceName="Design team"
initials="AL"
accentColor="#818CF8"
pushDistance={0.68}
openScale={0.86}
fontFamily="YourSansFont"
mediumFontFamily="YourMediumFont"
items={[
{ id: 'tasks', label: 'Tasks', icon: 'inbox', count: 8 },
{ id: 'calendar', label: 'Calendar', icon: 'calendar' },
]}
renderContent={(item) => <YourScreen destination={item.id} />}
onSelect={(item) => handleDestination(item.id)}
onUpgrade={() => handleUpgrade()}
/>
);
}The ref exposes open(), close(), highlight(id), and select(id). highlight moves the indicator while leaving the screen and drawer in place; select commits content and closes. renderContent also creates a decorative, inaccessible copy and an outgoing crossfade layer: keep rendering free of side effects. Use lifted state for content that must stay synchronized across those copies. IDs must be unique; use up to six destinations in a mobile layout.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
renderContent | (item: DepthDrawerItem) => ReactNode | Required | Content rendered in the main panel for the selected destination. |
items | readonly DepthDrawerItem[] | DEFAULT_ITEMS | Navigation destinations. |
initialId | string | undefined | Initially selected destination; falls back to the first item. |
brandName | string | 'Workspace' | Brand wordmark. |
workspaceName | string | 'Personal' | Workspace name. |
initials | string | 'JD' | User initials. |
accentColor | string | '#818CF8' | Primary accent. |
surfaceColor | string | '#F8FAFC' | Main panel background. |
fontFamily | string | undefined | Body font; defaults to the system font. |
mediumFontFamily | string | fontFamily | Medium font; defaults to fontFamily. |
pushDistance | number | 0.68 | Horizontal screen displacement as a width fraction. |
openScale | number | 0.86 | Open screen scale. |
upgradeLabel | string | 'Upgrade' | Upgrade button text. |
onUpgrade | () => void | undefined | Called when Upgrade is pressed. |
onSelect | (item: DepthDrawerItem) => void | undefined | Called when a destination is selected. |
onOpenChange | (open: boolean) => void | undefined | Called when the drawer opens or closes. |
onInteraction | () => void | undefined | Called on user input; use to stop demo autoplay. |
menuAccessibilityLabel | string | 'Open navigation menu' | Accessible name for the menu button. |
style | StyleProp<ViewStyle> | undefined | Container style. |
testID | string | undefined | Container test identifier. |
Compatibility
Expo SDK 58; React Native 0.88.0; Reanimated 4.7.1; Worklets 0.13.0; Gesture Handler 3.3. Expo Go includes the required modules. Icons are local SVG data sources rendered by expo-image. No runtime asset downloads.
Supports iOS, Android, and web. The demo's task list scrolls within its available space at 320×694 and 390×844.
Accessibility
Every button has a role and label, menu destinations expose selection, and important navigation changes are announced and mirrored in a polite live region. The hidden menu, decorative stack, and outgoing content are removed from the accessibility tree. Controls have minimum 44-point targets and separate 0.96 press feedback. Tap alternatives cover every gesture.
Reduced motion removes perspective, displacement, scaling, blur, and moving indicators. The main screen and menu use 200 ms fades; press scaling is disabled. Selecting a destination keeps the 200 ms content fade.
Icons: Lucide (ISC). The demo brand and cloud tile follow the local Nimbus kit.
Customization notes
Movement follows gestures, scroll position, or interruptible spring physics. A global duration is not exposed; reduced-motion fades retain their short fixed timing.




