ShipNative
PricingBlogFAQContact
Build my app
ShipNative
PricingBlogFAQContact
Build my app
GuideOctober 2026 · 7 min read · By Saurabh Singh

React Native Action Sheet: Native, Expo Hook, or Custom

An action sheet is the little menu that slides up from the bottom when you tap “more” on a post: Edit, Share, Delete in red, Cancel. iOS has had it since 2008 and React Native wraps it in core. Android has no equivalent widget, which is why every cross-platform answer is really two answers. This guide walks the three options that hold up in 2026, the setup for each, and the handful of details (the iPad popover, the destructive index, dark mode) that separate a sheet that feels native from one that feels pasted in.

The options, honestly

OptionPlatformsWhat it looks likePick it forThe cost
ActionSheetIOS (core)iOS onlyReal UIAlertController sheetZero dependencies, looks exactly like the OSNo-op on Android; you must branch yourself
@expo/react-native-action-sheetiOS · Android · WebNative on iOS, styled JS sheet on AndroidOne hook for both platforms, works in Expo GoAndroid sheet is plain; icons and theming need props
react-native-actions-sheetiOS · AndroidCustom JS sheet with gesturesAny content inside, snap points, routes within the sheetNot the OS look on iOS; more surface to style
@gorhom/bottom-sheetiOS · AndroidDraggable bottom sheetWhen the "sheet" is really a panel of contentHeavier setup; overkill for a three-item menu
zeego (native menus)iOS · AndroidContext menu / dropdownLong-press and button menus that feel nativeA 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). zeego wraps 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; read useColorScheme() 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.

Frequently Asked Questions

Does React Native have a built-in action sheet?

Only on iOS. ActionSheetIOS in React Native core wraps UIAlertController in action-sheet style and does nothing on Android. For both platforms use @expo/react-native-action-sheet, which calls ActionSheetIOS on iOS and renders a styled bottom sheet on Android, or build your own sheet with a Modal or a bottom-sheet library.

How do I show an action sheet in Expo?

Install @expo/react-native-action-sheet, wrap your root layout in ActionSheetProvider, then call showActionSheetWithOptions from the useActionSheet hook with an options array, a cancelButtonIndex, and optionally a destructiveButtonIndex. The callback receives the index the user tapped. It works in Expo Go because the iOS side is core React Native and the Android side is pure JavaScript.

Why does my action sheet crash on iPad?

On iPad an iOS action sheet is presented as a popover, and a popover needs something to point at. If you do not pass an anchor (a native node handle for the button that opened it) UIKit has no source view and throws. Pass anchor: findNodeHandle(buttonRef.current) in the options, or the library falls back to centering the sheet, which looks wrong but does not crash.

Action sheet or bottom sheet: which should I use?

An action sheet is a short list of actions about one thing: Edit, Share, Delete. It is modal, dismisses on any choice, and looks like the OS. A bottom sheet is a container for arbitrary content: a form, a map detail, a filter panel, often draggable and non-modal. If your list has more than five or six items or needs anything other than text rows, you want a bottom sheet.

How do I make an action sheet follow dark mode?

ActionSheetIOS follows the system appearance by default, and you can force it with the userInterfaceStyle option set to light or dark. On Android with @expo/react-native-action-sheet the sheet is your JavaScript, so pass containerStyle, textStyle, and titleTextStyle from your theme, or read useColorScheme and swap styles. A custom sheet is entirely yours to theme.

→

React Native Bottom Sheet

When the menu grows into a panel, this is the component you want.

Read guide →
→

React Native Modal: Which One to Use

Why a core Modal sits above everything, including your sheet.

Read guide →

Ship a real React Native app today

Describe, preview, and export Expo code, free to start.

Build with ShipNative
ShipNative

Build mobile apps with AI. Describe it, try it on your phone, own the code.

Features

Text to App AIApp Generator from ScreenshotPRD to Mobile App

Tools

All free toolsApp Cost CalculatorApp Name GeneratorApp Store Keyword ToolReact Native Components

Blog

All blog postsHow to Build an App Without CodingBest AI Tools for Real Mobile AppsExpo EAS App Store ChecklistLovable, Cursor & v0 for MobileBest AI App Builders in 2026React Native AI App Builder Guide
© 2026 ShipNative
PrivacyTermsAboutFAQPricingDiscordShipNative - Featured on Startup FameFeatured on Twelve ToolsFeatured on TinyShelfShipNative - Launched on Fazier