React Native Action Sheet
An iOS-style action sheet component for React Native with title, message, action buttons, cancel button, and destructive action styling.
Expo Snack
Code
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
Animated,
Dimensions,
Pressable,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface Action {
label: string;
onPress: () => void;
destructive?: boolean;
disabled?: boolean;
}
interface ActionSheetProps {
visible: boolean;
onClose: () => void;
title?: string;
message?: string;
actions: Action[];
cancelLabel: string;
}
/* ------------------------------------------------------------------ */
/* ActionSheet */
/* ------------------------------------------------------------------ */
const SCREEN_HEIGHT = Dimensions.get("window").height;
function ActionSheet({ visible, onClose, title, message, actions, cancelLabel }: ActionSheetProps) {
const backdrop = useRef(new Animated.Value(0)).current;
const slide = useRef(new Animated.Value(SCREEN_HEIGHT)).current;
const [mounted, setMounted] = useState(visible);
const open = useCallback(() => {
setMounted(true);
Animated.parallel([
Animated.timing(backdrop, {
toValue: 1,
duration: 250,
useNativeDriver: true,
}),
Animated.spring(slide, {
toValue: 0,
damping: 20,
stiffness: 200,
mass: 1,
useNativeDriver: true,
}),
]).start();
}, [backdrop, slide]);
const close = useCallback(() => {
Animated.parallel([
Animated.timing(backdrop, {
toValue: 0,
duration: 200,
useNativeDriver: true,
}),
Animated.timing(slide, {
toValue: SCREEN_HEIGHT,
duration: 200,
useNativeDriver: true,
}),
]).start(() => setMounted(false));
}, [backdrop, slide]);
useEffect(() => {
if (visible) {
open();
} else {
close();
}
}, [visible, open, close]);
if (!mounted) return null;
return (
<View style={styles.overlay}>
{/* Backdrop */}
<Animated.View
style={[
styles.backdrop,
{ opacity: backdrop.interpolate({ inputRange: [0, 1], outputRange: [0, 0.5] }) },
]}
>
<Pressable style={StyleSheet.absoluteFill} onPress={onClose} />
</Animated.View>
{/* Sheet */}
<Animated.View style={[styles.sheet, { transform: [{ translateY: slide }] }]}>
{/* Action group */}
<View style={styles.group}>
{/* Header */}
{(title || message) && (
<View style={styles.header}>
{title && <Text style={styles.title}>{title}</Text>}
{message && <Text style={styles.message}>{message}</Text>}
</View>
)}
{/* Action buttons */}
{actions.map((action, index) => {
const isFirst = index === 0 && !title && !message;
const isLast = index === actions.length - 1;
return (
<React.Fragment key={action.label}>
{(index > 0 || title || message) && <View style={styles.separator} />}
<TouchableOpacity
style={[
styles.button,
isFirst && styles.buttonFirst,
isLast && styles.buttonLast,
action.disabled && styles.buttonDisabled,
]}
activeOpacity={0.6}
disabled={action.disabled}
onPress={() => {
action.onPress();
onClose();
}}
>
<Text
style={[
styles.buttonText,
action.destructive && styles.destructiveText,
action.disabled && styles.disabledText,
]}
>
{action.label}
</Text>
</TouchableOpacity>
</React.Fragment>
);
})}
</View>
{/* Cancel button */}
<TouchableOpacity style={styles.cancelButton} activeOpacity={0.6} onPress={onClose}>
<Text style={styles.cancelText}>{cancelLabel}</Text>
</TouchableOpacity>
</Animated.View>
</View>
);
}
/* ------------------------------------------------------------------ */
/* Styles */
/* ------------------------------------------------------------------ */
const styles = StyleSheet.create({
overlay: {
...StyleSheet.absoluteFillObject,
justifyContent: "flex-end",
zIndex: 1000,
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: "#000",
},
sheet: {
paddingHorizontal: 8,
paddingBottom: 34,
},
group: {
backgroundColor: "#f1f1f1",
borderRadius: 14,
overflow: "hidden",
},
header: {
paddingVertical: 14,
paddingHorizontal: 16,
alignItems: "center",
},
title: {
fontSize: 13,
fontWeight: "600",
color: "#8e8e93",
textAlign: "center",
},
message: {
fontSize: 13,
color: "#8e8e93",
textAlign: "center",
marginTop: 2,
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: "#c8c8cc",
},
button: {
paddingVertical: 18,
alignItems: "center",
backgroundColor: "#f1f1f1",
},
buttonFirst: {
borderTopLeftRadius: 14,
borderTopRightRadius: 14,
},
buttonLast: {
borderBottomLeftRadius: 14,
borderBottomRightRadius: 14,
},
buttonDisabled: {
opacity: 0.4,
},
buttonText: {
fontSize: 20,
color: "#007aff",
},
destructiveText: {
color: "#ef4444",
},
disabledText: {
color: "#8e8e93",
},
cancelButton: {
marginTop: 8,
backgroundColor: "#fff",
borderRadius: 14,
paddingVertical: 18,
alignItems: "center",
},
cancelText: {
fontSize: 20,
fontWeight: "600",
color: "#007aff",
},
});
/* ------------------------------------------------------------------ */
/* Demo App */
/* ------------------------------------------------------------------ */
export default function App() {
const [visible, setVisible] = useState(false);
return (
<View style={appStyles.container}>
<TouchableOpacity
style={appStyles.openButton}
activeOpacity={0.8}
onPress={() => setVisible(true)}
>
<Text style={appStyles.openButtonText}>Open Action Sheet</Text>
</TouchableOpacity>
<ActionSheet
visible={visible}
onClose={() => setVisible(false)}
title="Photo Options"
message="Choose an action for this photo"
actions={[
{ label: "Take Photo", onPress: () => console.log("Take Photo") },
{ label: "Choose from Library", onPress: () => console.log("Choose from Library") },
{ label: "Delete Photo", onPress: () => console.log("Delete Photo"), destructive: true },
]}
cancelLabel="Cancel"
/>
</View>
);
}
const appStyles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#0f172a",
justifyContent: "center",
alignItems: "center",
},
openButton: {
backgroundColor: "#007aff",
paddingHorizontal: 24,
paddingVertical: 14,
borderRadius: 12,
},
openButtonText: {
color: "#fff",
fontSize: 17,
fontWeight: "600",
},
});React Native Action Sheet
A fully animated, iOS-style action sheet built with pure React Native. It slides up from the bottom of the screen with a spring animation and includes a dimmed backdrop overlay. Actions are rendered as grouped buttons with hairline separators, matching the native iOS look and feel. Destructive actions are highlighted in red, and the cancel button is visually separated with a gap.
Props
| Prop | Type | Required | Description |
|---|---|---|---|
visible | boolean | Yes | Controls whether the action sheet is displayed. |
onClose | () => void | Yes | Called when the backdrop or cancel button is pressed. |
title | string | No | Optional title displayed at the top of the sheet. |
message | string | No | Optional message displayed below the title. |
actions | Array<{ label: string; onPress: () => void; destructive?: boolean; disabled?: boolean }> | Yes | Array of action button configurations. |
cancelLabel | string | Yes | Label for the cancel button at the bottom. |
Usage
import ActionSheet from "./ActionSheet";
const [visible, setVisible] = useState(false);
<ActionSheet
visible={visible}
onClose={() => setVisible(false)}
title="Photo Options"
message="Choose an action for this photo"
actions={[
{ label: "Take Photo", onPress: () => console.log("camera") },
{ label: "Choose from Library", onPress: () => console.log("library") },
{ label: "Delete Photo", onPress: () => console.log("delete"), destructive: true },
]}
cancelLabel="Cancel"
/>
How it works
- Backdrop fade — An
Animated.Valuedrives the backdrop opacity from 0 to 0.5 whenvisiblebecomes true, creating a smooth dim effect over the content behind the sheet. - Slide-up spring — A second
Animated.Valuetranslates the sheet vertically. It starts off-screen at the bottom and springs upward usingAnimated.springfor a natural, bouncy entrance. - Grouped buttons — Action buttons are rendered inside a single rounded container with 1px separator lines between them, replicating the grouped-table style from iOS action sheets.
- Destructive styling — Any action marked
destructive: truerenders its label in red (#ef4444), signaling a potentially harmful operation. - Cancel separation — The cancel button sits in its own rounded container below the action group, separated by a small gap, matching the native iOS pattern.
- Dismiss — Tapping the backdrop or the cancel button triggers
onClose, which reverses the animations and hides the sheet.