Skip to content
All components

Free reference

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.