Free referenceMehdi Davoodi
React Native Action Sheet
React Native ships ActionSheetIOS, but it runs on iOS only, so the cross-platform action sheet is a transparent Modal holding a Reanimated view that springs up from the bottom behind a tappable backdrop that fades in.
Installation
npx expo install react-native-reanimated react-native-worklets react-native-safe-area-context
Reanimated 4 requires the New Architecture, the default in Expo SDK 54 and React Native
0.81, and it runs worklets through react-native-worklets, so install both.
Nothing else is needed: the sheet, the backdrop and the modal are core React Native.
Usage
import { useCallback, useEffect, useRef, useState } from 'react';
import {
Modal,
Pressable,
StyleSheet,
Text,
View,
useWindowDimensions,
} from 'react-native';
import Animated, {
Easing,
runOnJS,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export type SheetAction = {
label: string;
onPress: () => void;
destructive?: boolean;
};
type ActionSheetProps = {
visible: boolean;
title?: string;
actions: SheetAction[];
onClose: () => void;
};
const SPRING = { damping: 20, stiffness: 220, mass: 0.7 };
const OVERSHOOT = 24; // extra sheet background parked below the screen edge
export function ActionSheet({ visible, title, actions, onClose }: ActionSheetProps) {
const insets = useSafeAreaInsets();
const { height } = useWindowDimensions();
const [mounted, setMounted] = useState(false);
const pending = useRef<(() => void) | null>(null);
const visibleRef = useRef(visible);
const shown = useRef(false);
const translateY = useSharedValue(height);
const opacity = useSharedValue(0);
const runEntrance = useCallback(() => {
opacity.value = withTiming(1, { duration: 180, easing: Easing.out(Easing.quad) });
translateY.value = withSpring(0, SPRING);
}, [opacity, translateY]);
// Unmount the Modal only after the exit animation, then run the picked action.
const finishClose = useCallback(() => {
if (visibleRef.current) return; // reopened mid exit, stay mounted
shown.current = false;
setMounted(false);
const action = pending.current;
pending.current = null;
action?.();
}, []);
useEffect(() => {
visibleRef.current = visible;
if (visible) {
pending.current = null;
// Reopened before the exit finished: onShow will not fire a second time,
// so spring back up from wherever the sheet currently sits.
if (mounted && shown.current) runEntrance();
else setMounted(true);
return;
}
if (!mounted) return;
opacity.value = withTiming(0, { duration: 160 });
translateY.value = withTiming(
height,
{ duration: 220, easing: Easing.in(Easing.cubic) },
(finished) => {
if (finished) runOnJS(finishClose)();
},
);
}, [visible, mounted, height, opacity, translateY, finishClose, runEntrance]);
// onShow fires once the native modal window is on screen, so the spring
// starts on a frame the OS can actually render.
const handleShow = useCallback(() => {
shown.current = true;
translateY.value = height;
opacity.value = 0;
runEntrance();
}, [height, opacity, translateY, runEntrance]);
const select = useCallback(
(action: SheetAction) => {
pending.current = action.onPress;
onClose();
},
[onClose],
);
const backdropStyle = useAnimatedStyle(() => ({ opacity: opacity.value }));
const sheetStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }],
}));
return (
<Modal
visible={mounted}
transparent
animationType="none"
statusBarTranslucent
onShow={handleShow}
onRequestClose={onClose}
>
<View style={styles.root}>
<Animated.View style={[StyleSheet.absoluteFill, styles.backdrop, backdropStyle]}>
<Pressable
style={StyleSheet.absoluteFill}
onPress={onClose}
accessibilityRole="button"
accessibilityLabel="Close menu"
/>
</Animated.View>
<Animated.View
accessibilityViewIsModal
style={[
styles.sheet,
sheetStyle,
{ paddingBottom: insets.bottom + OVERSHOOT, marginBottom: -OVERSHOOT },
]}
>
<View style={styles.grabber} />
{title ? <Text style={styles.title}>{title}</Text> : null}
{actions.map((action, index) => (
<Pressable
key={action.label}
onPress={() => select(action)}
accessibilityRole="button"
style={({ pressed }) => [
styles.row,
index > 0 && styles.rowBorder,
pressed && styles.rowPressed,
]}
>
<Text style={[styles.rowLabel, action.destructive && styles.destructive]}>
{action.label}
</Text>
</Pressable>
))}
<Pressable
onPress={onClose}
accessibilityRole="button"
style={({ pressed }) => [styles.cancel, pressed && styles.rowPressed]}
>
<Text style={styles.cancelLabel}>Cancel</Text>
</Pressable>
</Animated.View>
</View>
</Modal>
);
}
export default function ActionSheetDemo() {
const [open, setOpen] = useState(false);
const [last, setLast] = useState('Nothing picked yet');
return (
<View style={styles.screen}>
<Pressable style={styles.trigger} onPress={() => setOpen(true)}>
<Text style={styles.triggerLabel}>Open action sheet</Text>
</Pressable>
<Text style={styles.result}>{last}</Text>
<ActionSheet
visible={open}
title="Attach a file"
onClose={() => setOpen(false)}
actions={[
{ label: 'Take photo', onPress: () => setLast('Took a photo') },
{ label: 'Choose from library', onPress: () => setLast('Opened the library') },
{ label: 'Delete draft', destructive: true, onPress: () => setLast('Deleted the draft') },
]}
/>
</View>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: 16,
backgroundColor: '#ffffff',
},
trigger: {
backgroundColor: '#111111',
paddingHorizontal: 22,
paddingVertical: 14,
borderRadius: 999,
},
triggerLabel: { color: '#ffffff', fontSize: 16, fontWeight: '600' },
result: { color: '#6b7280', fontSize: 14 },
root: { flex: 1, justifyContent: 'flex-end' },
backdrop: { backgroundColor: 'rgba(0, 0, 0, 0.45)' },
sheet: {
backgroundColor: '#ffffff',
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
paddingTop: 8,
paddingHorizontal: 8,
shadowColor: '#000000',
shadowOpacity: 0.18,
shadowRadius: 24,
shadowOffset: { width: 0, height: -6 },
elevation: 24,
},
grabber: {
alignSelf: 'center',
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: '#d4d4d8',
marginBottom: 4,
},
title: { textAlign: 'center', fontSize: 13, color: '#6b7280', paddingVertical: 10 },
row: { paddingVertical: 16, paddingHorizontal: 12, borderRadius: 14 },
rowBorder: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: '#e5e7eb' },
rowPressed: { backgroundColor: '#f4f4f5' },
rowLabel: { fontSize: 17, color: '#111827', textAlign: 'center' },
destructive: { color: '#dc2626' },
cancel: {
marginTop: 8,
paddingVertical: 16,
borderRadius: 14,
backgroundColor: '#f4f4f5',
},
cancelLabel: { fontSize: 17, fontWeight: '600', color: '#111827', textAlign: 'center' },
});
How it works
There is a real fork here.
ActionSheetIOS is
built into React Native and returns the genuine system sheet in one call, but it exists on
iOS only: on Android the module is missing and the call throws rather than quietly doing
nothing. So you either branch by platform or draw one sheet for both. This is the second
option: a transparent Modal with
animationType="none", so the OS animates nothing and Reanimated owns every
pixel.
The Modal is held open by local mounted state, not by the
visible prop. When the parent flips visible to false the exit
runs first, and only its completion callback, sent back with runOnJS,
unmounts the Modal, unless visible flipped back to true in the meantime, in
which case the sheet springs straight back up. The entrance fires from
onShow, once the native window exists, which avoids dropped first frames.
For screen readers, accessibilityViewIsModal keeps VoiceOver off the screen
behind the sheet and the backdrop is a labeled button rather than a bare view.
Backdrop opacity uses a timing curve, since a spring on opacity can overshoot
past 1 and clamp visibly; the sheet gets the spring instead. A spring that lands well
overshoots upward a few pixels, so OVERSHOOT, extra bottom padding plus an
equal negative bottom margin, parks a strip of the sheet's own background below the screen
edge. useSafeAreaInsets covers the rest. For a pre-built route,
@expo/react-native-action-sheet
wraps ActionSheetIOS on iOS and renders its own sheet on Android.
Gotchas
Android needs onRequestClose
The hardware back button reaches your sheet only through the Modal's
onRequestClose prop. Leave it out and back does nothing while the sheet is
open. Point it at the same onClose the backdrop uses so every dismissal path
runs the same exit animation.
Safe area insets inside a Modal
A Modal is a separate native window, and on Android useSafeAreaInsets
measures against the root view, so it can return zero inside one. If the bottom padding
collapses, render a nested SafeAreaProvider inside the Modal or pass
initialWindowMetrics to the provider at the app root. Expo Router mounts a
provider for you; a bare React Native app adds one in App.tsx.
Dismiss the keyboard before opening
Sheets are usually opened from a screen with a focused TextInput, such as a
composer. Call Keyboard.dismiss() before you set visible.
Otherwise the keyboard and the modal window animate at once, the sheet lands halfway up
the screen on Android, and the layout jumps when the keyboard finally leaves.
Run the action after the sheet is gone
The pending ref is not decoration. If a row opens the camera roll or an
alert, iOS refuses to present it while the first modal is still dismissing and the tap
silently does nothing. Store the callback, close, fire it from the completion handler.
Does React Native have a built-in action sheet?
Partly. React Native ships ActionSheetIOS.showActionSheetWithOptions, which
presents the real iOS system sheet with destructive and cancel button indexes. It is iOS
only, and on Android the call throws instead of quietly doing nothing, so it cannot be the
whole answer in a cross-platform app. On Android you build the sheet yourself from a Modal
and an animated view, as above, or use a library that branches for you.
How do I show an action sheet on Android?
Render a transparent Modal holding an absolutely positioned backdrop and a
panel anchored to the bottom, then animate the panel's translateY from off
screen to zero with Reanimated while the backdrop fades in. Wire
onRequestClose to your close handler so the hardware back button dismisses
it, and add useSafeAreaInsets().bottom as bottom padding so the last row
clears the gesture bar.
Should I use @expo/react-native-action-sheet?
Use it when you want the true iOS system sheet, are happy with its Android rendering, and
a hook API (showActionSheetWithOptions from a provider) suits your screens.
Build your own, as on this page, when the sheet has to match a custom design system, hold
icons or non-text rows, or animate the way the rest of your app animates. The hand-built
version is one file and leans only on Reanimated and safe area context.
How do I make the sheet draggable to dismiss?
Add react-native-gesture-handler and wrap the panel in a Pan gesture: write
the drag into the same translateY shared value, clamp it so the sheet cannot
be pulled above its resting position, and on release spring back to zero or, if distance
or velocity crosses your threshold, animate down and call the close handler through
runOnJS. For snap points, a scrollable body and keyboard handling, reach for
@gorhom/bottom-sheet instead.
Depends onreact-native-reanimatedreact-native-workletsreact-native-safe-area-context
Every component on this page is free to copy. Motionary sells the polished, production versions over at the catalog.