The options, honestly
| Option | Platforms | What it looks like | Pick it for | The cost |
|---|---|---|---|---|
ActionSheetIOS (core) | iOS only | Real UIAlertController sheet | Zero dependencies, looks exactly like the OS | No-op on Android; you must branch yourself |
@expo/react-native-action-sheet | iOS · Android · Web | Native on iOS, styled JS sheet on Android | One hook for both platforms, works in Expo Go | Android sheet is plain; icons and theming need props |
react-native-actions-sheet | iOS · Android | Custom JS sheet with gestures | Any content inside, snap points, routes within the sheet | Not the OS look on iOS; more surface to style |
@gorhom/bottom-sheet | iOS · Android | Draggable bottom sheet | When the "sheet" is really a panel of content | Heavier setup; overkill for a three-item menu |
zeego (native menus) | iOS · Android | Context menu / dropdown | Long-press and button menus that feel native | A menu, not a sheet; needs a dev build |
Default pick: @expo/react-native-action-sheet. It gives iOS users the real thing, gives Android users something reasonable, runs in Expo Go, and the API is one function call. Drop down to ActionSheetIOSdirectly only if you are already branching per platform. Move up to a bottom sheet the moment your “menu” wants an icon, a subtitle, or a toggle.
Option 1: ActionSheetIOS, the core API
Worth knowing even if you never call it, because the Expo library is built on top of it and inherits its option names. It is an imperative function with a callback:
import { ActionSheetIOS, Platform } from 'react-native';
export function showPostMenu(onEdit: () => void, onDelete: () => void) {
if (Platform.OS !== 'ios') return; // does nothing elsewhere
const options = ['Edit', 'Delete', 'Cancel'];
ActionSheetIOS.showActionSheetWithOptions(
{
title: 'This post',
options,
destructiveButtonIndex: 1, // renders red
cancelButtonIndex: 2, // separated at the bottom, also fired on tap-outside
userInterfaceStyle: 'dark', // or 'light'; omit to follow the system
},
(index) => {
if (index === 0) onEdit();
if (index === 1) onDelete();
// index === 2 or undefined: cancelled
},
);
}Three things to carry into any implementation. The cancel button must be in the options array; the index just tells iOS which one to style and separate. The destructive index renders red and is the only visual emphasis the sheet offers. And the callback is called with the index of whatever the user picked, including cancel, so your handler is a switch on numbers, which gets fragile as soon as options are conditional. Build the array and the handlers together.
Option 2: @expo/react-native-action-sheet, both platforms
Same option names, plus a provider and a hook, plus an Android sheet rendered in JavaScript. Install it, wrap the root once, and call it from anywhere:
npx expo install @expo/react-native-action-sheet// app/_layout.tsx (Expo Router)
import { Stack } from 'expo-router';
import { ActionSheetProvider } from '@expo/react-native-action-sheet';
export default function RootLayout() {
return (
<ActionSheetProvider>
<Stack />
</ActionSheetProvider>
);
}// PostMenuButton.tsx
import { useRef } from 'react';
import { findNodeHandle, Pressable, Text } from 'react-native';
import { useActionSheet } from '@expo/react-native-action-sheet';
export function PostMenuButton({ onEdit, onShare, onDelete }) {
const { showActionSheetWithOptions } = useActionSheet();
const anchorRef = useRef(null);
const open = () => {
// Build options and handlers together so indices cannot drift.
const items = [
{ label: 'Edit', onPress: onEdit },
{ label: 'Share', onPress: onShare },
{ label: 'Delete', onPress: onDelete, destructive: true },
{ label: 'Cancel', cancel: true },
];
showActionSheetWithOptions(
{
options: items.map((i) => i.label),
destructiveButtonIndex: items.findIndex((i) => i.destructive),
cancelButtonIndex: items.findIndex((i) => i.cancel),
// iPad: the sheet is a popover and must point at something.
anchor: findNodeHandle(anchorRef.current) ?? undefined,
// Android-only styling; ignored on iOS.
containerStyle: { backgroundColor: '#1c1c1e' },
textStyle: { color: '#fff' },
},
(index) => {
if (index === undefined) return;
items[index]?.onPress?.();
},
);
};
return (
<Pressable ref={anchorRef} onPress={open} hitSlop={8} accessibilityLabel="More options">
<Text>•••</Text>
</Pressable>
);
}The items-array pattern is the one habit worth forming. The moment you hide “Delete” for posts the user does not own, hard-coded indices break silently and the wrong handler fires. Deriving the indices from the same array that produces the labels removes that whole class of bug.
On Android the library also accepts icons (an array of React elements aligned with options), showSeparators, and titleTextStyle. On iOS those are ignored, because the native sheet cannot show them. That asymmetry is the price of the native look and it is a fair one.
The iPad popover problem
On iPhone an action sheet slides up from the bottom. On iPad UIKit presents the same controller as a popover, and a popover must be anchored to a view. Without an anchor, older React Native versions crashed outright with an unsatisfiable constraint error; current ones fall back to a sheet floating in the centre of the screen, which looks like a bug to anyone who owns an iPad.
The fix is the anchor option shown above: a native node handle for the button that opened the sheet. Always pass it. It costs one ref and is ignored on iPhone and Android. If your app sets supportsTablet in app.json, this is not optional; the App Store reviewers test on iPad.
Option 3: a custom sheet when the menu stops being a menu
The native sheet is text rows and nothing else. The first time a designer asks for icons on iOS, a subtitle under an option, a toggle, or more than six items, you have left action-sheet territory. At that point the honest move is a bottom sheet: your own component, your own styling, same on both platforms.
For a quick one with no extra dependency, a core Modal with a dimmed backdrop and a bottom-aligned card is forty lines:
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
type Item = { label: string; onPress: () => void; destructive?: boolean };
export function SimpleActionSheet({ visible, title, items, onClose }:
{ visible: boolean; title?: string; items: Item[]; onClose: () => void }) {
const insets = useSafeAreaInsets();
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
{/* Backdrop: tap outside dismisses */}
<Pressable style={styles.backdrop} onPress={onClose} accessibilityLabel="Dismiss" />
<View style={[styles.sheet, { paddingBottom: insets.bottom + 8 }]} accessibilityRole="menu">
{title ? <Text style={styles.title}>{title}</Text> : null}
{items.map((item) => (
<Pressable
key={item.label}
accessibilityRole="menuitem"
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => { onClose(); item.onPress(); }}
>
<Text style={[styles.label, item.destructive && styles.destructive]}>{item.label}</Text>
</Pressable>
))}
<Pressable style={[styles.row, styles.cancel]} onPress={onClose} accessibilityRole="button">
<Text style={[styles.label, { fontWeight: '600' }]}>Cancel</Text>
</Pressable>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.4)' },
sheet: { backgroundColor: '#1c1c1e', borderTopLeftRadius: 16, borderTopRightRadius: 16, paddingTop: 8 },
title: { color: 'rgba(255,255,255,0.6)', fontSize: 13, textAlign: 'center', paddingVertical: 10 },
row: { paddingVertical: 16, paddingHorizontal: 20 },
rowPressed: { backgroundColor: 'rgba(255,255,255,0.06)' },
label: { color: '#fff', fontSize: 17, textAlign: 'center' },
destructive: { color: '#ff453a' },
cancel: { marginTop: 8, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: 'rgba(255,255,255,0.15)' },
});Two caveats. A core Modal opens its own native window, so a toast or another modal will not render above it; the modal guide explains the layering. And once you want drag-to-dismiss or snap points, stop extending this and switch to a real bottom sheet. Rebuilding gesture physics by hand is where afternoons go.
Details that make it feel native
- Confirm destructive actions, do not stack sheets. Tapping Delete in a sheet should open an
Alert, not a second sheet. iOS will refuse to present a second action sheet while the first is dismissing anyway. - Keep it under six options. Past that the iOS sheet scrolls and the Android one fills the screen. Group into a bottom sheet with sections instead.
- Cancel is implicit on Android. Material design dismisses on back press and tap-outside, so a visible Cancel row reads as iOS-flavoured. The Expo library shows it anyway for consistency; hide it with
Platform.OS === 'android'if your Android users notice. - For long-press menus, use a menu. A context menu on a list item is a different native control (UIMenu on iOS, a popup on Android).
zeegowraps both with one API, but needs a dev build, not Expo Go. - Dark mode. iOS follows the system unless you set
userInterfaceStyle. Your Android and custom sheets do not; readuseColorScheme()and pass the styles. The dark mode guide has the theme wiring.
Skip the wiring
A provider at the root, an items array that keeps indices honest, an iPad anchor, a confirm alert on delete. It is not much code, and it is the same code in every app that has a “more” button. ShipNative generates a real Expo app from a description with these interaction patterns in place, previews it on your phone, and gives you the full React Native project to keep. Describe the screen, then edit the sheet like any file you wrote yourself.