Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/drawer-react-native-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@razorpay/blade": minor
---

feat(rn): add React Native support for Drawer component
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ThemeContext } from './useTheme';
import { useBladeProvider } from './useBladeProvider';
import type { BladeProviderProps } from './types';
import { BottomSheetStackProvider } from '~components/BottomSheet/BottomSheetStack';
import { DrawerStackProvider } from '~components/Drawer/StackProvider';

const gestureHandlerStyle = {
flex: 1,
Expand All @@ -23,7 +24,9 @@ const BladeProvider = ({
<PortalProvider>
<ThemeContext.Provider value={themeContextValue}>
<StyledComponentThemeProvider theme={theme}>
<BottomSheetStackProvider>{children}</BottomSheetStackProvider>
<DrawerStackProvider>
<BottomSheetStackProvider>{children}</BottomSheetStackProvider>
</DrawerStackProvider>
<PortalHost name="BladeBottomSheetPortal" />
</StyledComponentThemeProvider>
</ThemeContext.Provider>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import React from 'react';
import styled from 'styled-components/native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
runOnJS,
cancelAnimation,
} from 'react-native-reanimated';
import { Pressable, useWindowDimensions } from 'react-native';
import type { ElevationStyles } from '~tokens/global/elevation';
import BaseBox from '~components/Box/BaseBox';
import { getElevationValue } from '~components/Box/BaseBox/baseBoxStyles';
import { useTheme } from '~components/BladeProvider';
import { makeAccessible } from '~utils/makeAccessible';

const fillStyle = {
position: 'absolute' as const,
top: 0,
left: 0,
right: 0,
bottom: 0,
};

// Outer animated wrapper carries the slide/opacity animation + elevation shadow.
// Shadow lives here (NOT on the inner surface) because the inner surface uses
// `overflow: 'hidden'` for the Android border-radius clip fix, which would clip the shadow.
const StyledDrawerWrapper = styled(Animated.View)(() => {
return {
position: 'absolute' as const,
top: 0,
bottom: 0,
right: 0,
width: '90%',
flexDirection: 'column' as const,
};
});

const StyledDrawerSurface = styled(BaseBox)(({ theme }) => {
return {
flex: 1,
backgroundColor: theme.colors.popup.background.gray.subtle,
// base breakpoint renders the drawer edge-to-edge with no radius (matches web's phone view)
borderRadius: 0,
overflow: 'hidden' as const,
flexDirection: 'column' as const,
};
});

const StyledOverlay = styled(Animated.View)(({ theme }) => {
return {
...fillStyle,
backgroundColor: theme.colors.overlay.background.subtle,
};
});

type AnimatedDrawerContainerProps = {
/**
* Drives the enter/exit animation. When `true` the drawer slides in and the
* overlay fades in; when `false` it slides out and fades away.
*/
isVisible: boolean;
/**
* Whether to render the dismissible overlay behind the drawer surface.
*/
showOverlay: boolean;
/**
* Called when the user presses the overlay to dismiss the drawer.
*/
onOverlayPress: () => void;
/**
* Called after the exit animation completes (parity with web `onUnmount`).
*/
onExitComplete?: () => void;
/**
* Accessibility label announced for the drawer dialog.
*/
accessibilityLabel?: string;
/**
* Whether this drawer is the first (bottom-most) in a stack of 2+ open drawers.
* When true, the resting translateX is offset slightly so the first drawer peeks
* out behind the stacked drawer — matching web's stacked-drawer positioning.
*/
isFirstDrawerInStack?: boolean;
/**
* Drawer content (DrawerHeader / DrawerBody / DrawerFooter).
*/
children: React.ReactNode;
};

/**
* Encapsulates the reanimated slide-in surface + fading overlay for the native
* Drawer. Mirrors the web `AnimatedDrawerContainer` styled component but drives
* `translateX`/`opacity` via reanimated shared values instead of CSS transitions.
*/
const AnimatedDrawerContainer = React.forwardRef<
React.ComponentRef<typeof StyledDrawerWrapper>,
AnimatedDrawerContainerProps
>(
(
{
isVisible,
showOverlay,
onOverlayPress,
onExitComplete,
accessibilityLabel,
isFirstDrawerInStack = false,
children,
},
ref,
): React.ReactElement => {
const { theme } = useTheme();
const { width: screenWidth } = useWindowDimensions();
// Always initialize the shared values at the CLOSED / off-screen state
// (translateX = screenWidth, opacity = 0) so there is a "from" frame to animate FROM
// when the drawer mounts open. The Portal mounts fresh on open, so this container
// mounts with `isVisible = true`; the mount effect below then drives `withTiming` to
// the open state, producing the slide-in.
//
// Initializing from the current visibility instead (open → translateX 0) meant that on
// open the surface was already at its resting position, so the enter `withTiming(0)`
// had nothing to animate and the panel snapped into place instantly. The exit still
// animated (0 → screenWidth) which is why only the enter transition looked broken.
const translateX = useSharedValue(screenWidth);
const surfaceOpacity = useSharedValue(0);
const overlayOpacity = useSharedValue(0);

const shadow = (getElevationValue('highRaised', theme) as unknown) as ElevationStyles;

const onExitCompleteRef = React.useRef(onExitComplete);
React.useEffect(() => {
onExitCompleteRef.current = onExitComplete;
}, [onExitComplete]);

React.useEffect(() => {
// Mirror web's Drawer surface transition (Drawer.web.tsx): enter uses
// `duration.xmoderate` + `easing.entrance`, exit uses `duration.moderate` +
// `easing.exit`. Previously enter used the slower `gentle` (480ms), which made the
// native open feel sluggish compared to web's 360ms slide-in.
const enterConfig = {
duration: theme.motion.duration.xmoderate,
easing: theme.motion.easing.entrance,
};
const exitConfig = {
duration: theme.motion.duration.moderate,
easing: theme.motion.easing.exit,
};
const config = isVisible ? enterConfig : exitConfig;

// When this drawer is the first in a stack (another drawer is open on top),
// offset the resting position slightly to the left so the first drawer peeks
// out behind the stacked drawer — matching web's stacked-drawer positioning.
const stackOffset = isFirstDrawerInStack ? -theme.spacing[5] : 0;
translateX.value = withTiming(isVisible ? stackOffset : screenWidth, config);
surfaceOpacity.value = withTiming(isVisible ? 1 : 0, config);
overlayOpacity.value = withTiming(isVisible ? 1 : 0, config, (finished) => {
if (finished && !isVisible && onExitCompleteRef.current) {
runOnJS(onExitCompleteRef.current)();
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isVisible, screenWidth]);

// Cancel all in-flight animations on unmount to prevent UI-thread worklets
// from continuing to animate shared values after the component is gone.
React.useEffect(() => {
return () => {
cancelAnimation(translateX);
cancelAnimation(surfaceOpacity);
cancelAnimation(overlayOpacity);
};
}, [translateX, surfaceOpacity, overlayOpacity]);

const surfaceAnimatedStyle = useAnimatedStyle(() => {
return {
opacity: surfaceOpacity.value,
transform: [{ translateX: translateX.value }],
};
});

const overlayAnimatedStyle = useAnimatedStyle(() => {
return {
opacity: overlayOpacity.value,
};
});

return (
<>
{showOverlay ? (
<Pressable
onPress={onOverlayPress}
style={fillStyle}
{...makeAccessible({ role: 'button', label: 'Dismiss' })}
>
<StyledOverlay style={overlayAnimatedStyle} />
</Pressable>
) : null}
<StyledDrawerWrapper ref={ref} style={[shadow, surfaceAnimatedStyle]}>
<StyledDrawerSurface
{...makeAccessible({
role: 'dialog',
modal: true,
label: accessibilityLabel,
})}
>
{children}
</StyledDrawerSurface>
</StyledDrawerWrapper>
</>
);
},
);

export { AnimatedDrawerContainer };
Loading
Loading