Action Sheet
Actions from the bottom edge with Cancel apart: it springs up, follows a downward drag and drops away.
import { useContext, useEffect, useState } from 'react';
import {
Modal,
Pressable,
StyleSheet,
Text,
View,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler';
import Animated, {
interpolate,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { SafeAreaInsetsContext } from 'react-native-safe-area-context';
import { scheduleOnRN } from 'react-native-worklets';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { durations, easings, spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type SheetAction = {
label: string;
onPress: () => void;
/** "danger" is drawn in red, for actions that destroy something. */
tone?: 'default' | 'danger';
disabled?: boolean;
};
export type ActionSheetProps = {
visible: boolean;
/** Called whenever the sheet closes: Cancel, a tap outside, a swipe down, Back. */
onClose: () => void;
actions: readonly SheetAction[];
/** A short line above the actions, e.g. what they apply to. */
title?: string;
message?: string;
/** Defaults to "Cancel". */
cancelLabel?: string;
/** A tick when an action is chosen. Defaults to true. */
haptics?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const ROW = 56;
const MARGIN = 10;
const TRAVEL = 480;
/**
* A sheet of actions from the bottom edge, with Cancel in its own card. It
* springs up over a dimmed screen, follows the finger when dragged down and
* drops away past a threshold; a chosen action runs once the sheet is gone.
*/
export function ActionSheet({
visible,
onClose,
actions,
title,
message,
cancelLabel = 'Cancel',
haptics = true,
testID,
style,
}: ActionSheetProps) {
const { colors, font, radius, shadow } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const insets = useContext(SafeAreaInsetsContext);
const [mounted, setMounted] = useState(visible);
if (visible && !mounted) setMounted(true);
const t = useSharedValue(0);
const drag = useSharedValue(0);
const height = useSharedValue(TRAVEL);
useEffect(() => {
if (visible) {
drag.set(0);
t.set(reduced ? 1 : withSpring(1, spring('smooth')));
return;
}
if (reduced) {
t.set(0);
setTimeout(() => setMounted(false), 0);
return;
}
t.set(
withTiming(0, { duration: durations.base, easing: easings.exit }, (done) => {
if (done) scheduleOnRN(setMounted, false);
}),
);
}, [visible, reduced, t, drag]);
// A chosen action runs after the sheet has gone, so it never fights the exit.
const choose = (action: SheetAction) => {
haptic('tick');
onClose();
setTimeout(action.onPress, reduced ? 0 : durations.base);
};
const pan = Gesture.Pan()
.withTestId('action-sheet')
.activeOffsetY(6)
.failOffsetX([-20, 20])
.onUpdate((e) => {
// Upwards it gives a little and resists; downwards it follows the finger.
drag.set(e.translationY > 0 ? e.translationY : -Math.sqrt(-e.translationY) * 2);
})
.onEnd((e) => {
if (e.translationY > height.get() * 0.28 || e.velocityY > 900) {
scheduleOnRN(onClose);
} else {
drag.set(withSpring(0, spring('bouncy', reduced)));
}
});
const scrim = useAnimatedStyle(() => ({
opacity: t.get() * (1 - Math.min(1, Math.max(0, drag.get()) / Math.max(1, height.get()))),
}));
const sheet = useAnimatedStyle(() => ({
transform: [{ translateY: interpolate(t.get(), [0, 1], [height.get() + 40, 0]) + drag.get() }],
}));
return (
<Modal
visible={mounted}
transparent
animationType="none"
onRequestClose={onClose}
statusBarTranslucent
navigationBarTranslucent
>
{/* A modal is its own window on Android, so gestures need their own root. */}
<GestureHandlerRootView style={styles.fill}>
<View style={styles.root} onAccessibilityEscape={onClose}>
<Animated.View
style={[StyleSheet.absoluteFill, { backgroundColor: colors.scrim }, scrim]}
>
<Pressable
style={StyleSheet.absoluteFill}
onPress={onClose}
accessibilityRole="button"
accessibilityLabel="Close"
/>
</Animated.View>
<GestureDetector gesture={pan}>
<Animated.View
collapsable={false}
testID={testID}
onLayout={(e) => height.set(e.nativeEvent.layout.height)}
style={[
styles.sheet,
{ paddingBottom: MARGIN + (insets?.bottom ?? 0), gap: 8 },
style,
sheet,
]}
>
<View
style={[
styles.card,
{
backgroundColor: colors.surfaceRaised,
borderRadius: radius.xl,
boxShadow: shadow.lg,
},
]}
>
<View style={[styles.grabber, { backgroundColor: colors.borderStrong }]} />
{title || message ? (
<View style={[styles.head, { borderBottomColor: colors.border }]}>
{title ? (
<Text
accessibilityRole="header"
style={[
styles.center,
{
color: colors.textMuted,
fontSize: font.size.sm,
fontWeight: font.weight.semibold,
},
]}
>
{title}
</Text>
) : null}
{message ? (
<Text
style={[
styles.center,
{ color: colors.textSubtle, fontSize: font.size.xs },
]}
>
{message}
</Text>
) : null}
</View>
) : null}
{actions.map((a, i) => (
<Pressable
key={a.label}
onPress={() => choose(a)}
disabled={a.disabled}
accessibilityRole="button"
accessibilityLabel={a.label}
accessibilityState={{ disabled: a.disabled }}
style={({ pressed }) => [
styles.row,
i > 0 && {
borderTopColor: colors.border,
borderTopWidth: StyleSheet.hairlineWidth,
},
{
backgroundColor: pressed ? colors.surfaceSunken : undefined,
opacity: a.disabled ? 0.4 : 1,
},
]}
>
<Text
style={{
color: a.tone === 'danger' ? colors.danger : colors.text,
fontSize: font.size.lg,
}}
>
{a.label}
</Text>
</Pressable>
))}
</View>
<Pressable
onPress={onClose}
accessibilityRole="button"
accessibilityLabel={cancelLabel}
style={({ pressed }) => [
styles.row,
styles.card,
{
backgroundColor: pressed ? colors.surfaceSunken : colors.surfaceRaised,
borderRadius: radius.xl,
},
]}
>
<Text
style={{
color: colors.text,
fontSize: font.size.lg,
fontWeight: font.weight.semibold,
}}
>
{cancelLabel}
</Text>
</Pressable>
</Animated.View>
</GestureDetector>
</View>
</GestureHandlerRootView>
</Modal>
);
}
const styles = StyleSheet.create({
fill: { flex: 1 },
root: { flex: 1, justifyContent: 'flex-end' },
sheet: { paddingHorizontal: MARGIN },
card: { overflow: 'hidden' },
grabber: {
alignSelf: 'center',
width: 36,
height: 4,
borderRadius: 2,
marginTop: 8,
marginBottom: 2,
},
head: {
paddingHorizontal: 20,
paddingTop: 8,
paddingBottom: 14,
gap: 4,
borderBottomWidth: StyleSheet.hairlineWidth,
},
center: { textAlign: 'center' },
row: { height: ROW, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 20 },
});Installation
npx kinetik-ui add action-sheetpnpm dlx kinetik-ui add action-sheetbunx kinetik-ui add action-sheetyarn dlx kinetik-ui add action-sheetThe CLI copies the source and installs native dependencies with npx expo install, so versions match your Expo SDK. With shadcn: npx shadcn@latest add https://kinetik-ui.dev/r/action-sheet.json
Install manually
- Install dependencies
npx expo install react-native-gesture-handler react-native-reanimated react-native-safe-area-context react-native-worklets - Copy the source files
components/kinetik/action-sheet.tsx
Usage
import { ActionSheet } from '@/components/kinetik/action-sheet';
<ActionSheet
visible={open}
onClose={() => setOpen(false)}
title="Sunset at the pier.jpg"
actions={[
{ label: 'Share', onPress: share },
{ label: 'Delete photo', tone: 'danger', onPress: remove },
]}
/>;When to use
- A short list of things to do with one object: a photo, a message, a file.
- Phone layouts, where the actions are easiest to reach at the bottom edge.
When not to use
- A single confirmation. Use Pop Dialog.
- Menus attached to a button on wide screens. Use Pop Menu.
Notes
- A chosen action runs after the sheet has closed, so navigation or a dialog it opens never overlaps the exit.
- Drag the sheet down past about a quarter of its height, or flick it, to close it; a shorter drag springs back.
- The bottom padding follows the safe area when the app has a
SafeAreaProvider(Expo Router adds one). - With reduced motion it appears and disappears without sliding.
Props
ActionSheetProps
| Prop | Type | Default | Description |
|---|---|---|---|
visiblerequired | boolean | — | |
onCloserequired | () => void | — | Called whenever the sheet closes: Cancel, a tap outside, a swipe down, Back. |
actionsrequired | readonly SheetAction[] | — | |
title | string | — | A short line above the actions, e.g. what they apply to. |
message | string | — | |
cancelLabel | string | 'Cancel' | Defaults to "Cancel". |
haptics | boolean | true | A tick when an action is chosen. Defaults to true. |
testID | string | — | |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- A transparent modal of plain views; the sheet and scrim read two shared values. Nothing runs at rest.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 1.06× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 141 MB app memory. p99 spikes are the first frame of the modal window opening.
Real-device measurement pending.
Accessibility
- Reduced motion
- The sheet appears and disappears without sliding.
- Screen readers
- A heading and a button per action, plus Cancel; Back and the escape gesture close it.
- Touch target
- Every action and Cancel are full-width rows 56 pt tall.
Try it on a device
Scan with a phone that has the Kinetik playground installed to open this demo with real haptics and sensors.
Source files
components/kinetik/action-sheet.tsx
import { useContext, useEffect, useState } from 'react';
import {
Modal,
Pressable,
StyleSheet,
Text,
View,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler';
import Animated, {
interpolate,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { SafeAreaInsetsContext } from 'react-native-safe-area-context';
import { scheduleOnRN } from 'react-native-worklets';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { durations, easings, spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type SheetAction = {
label: string;
onPress: () => void;
/** "danger" is drawn in red, for actions that destroy something. */
tone?: 'default' | 'danger';
disabled?: boolean;
};
export type ActionSheetProps = {
visible: boolean;
/** Called whenever the sheet closes: Cancel, a tap outside, a swipe down, Back. */
onClose: () => void;
actions: readonly SheetAction[];
/** A short line above the actions, e.g. what they apply to. */
title?: string;
message?: string;
/** Defaults to "Cancel". */
cancelLabel?: string;
/** A tick when an action is chosen. Defaults to true. */
haptics?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const ROW = 56;
const MARGIN = 10;
const TRAVEL = 480;
/**
* A sheet of actions from the bottom edge, with Cancel in its own card. It
* springs up over a dimmed screen, follows the finger when dragged down and
* drops away past a threshold; a chosen action runs once the sheet is gone.
*/
export function ActionSheet({
visible,
onClose,
actions,
title,
message,
cancelLabel = 'Cancel',
haptics = true,
testID,
style,
}: ActionSheetProps) {
const { colors, font, radius, shadow } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const insets = useContext(SafeAreaInsetsContext);
const [mounted, setMounted] = useState(visible);
if (visible && !mounted) setMounted(true);
const t = useSharedValue(0);
const drag = useSharedValue(0);
const height = useSharedValue(TRAVEL);
useEffect(() => {
if (visible) {
drag.set(0);
t.set(reduced ? 1 : withSpring(1, spring('smooth')));
return;
}
if (reduced) {
t.set(0);
setTimeout(() => setMounted(false), 0);
return;
}
t.set(
withTiming(0, { duration: durations.base, easing: easings.exit }, (done) => {
if (done) scheduleOnRN(setMounted, false);
}),
);
}, [visible, reduced, t, drag]);
// A chosen action runs after the sheet has gone, so it never fights the exit.
const choose = (action: SheetAction) => {
haptic('tick');
onClose();
setTimeout(action.onPress, reduced ? 0 : durations.base);
};
const pan = Gesture.Pan()
.withTestId('action-sheet')
.activeOffsetY(6)
.failOffsetX([-20, 20])
.onUpdate((e) => {
// Upwards it gives a little and resists; downwards it follows the finger.
drag.set(e.translationY > 0 ? e.translationY : -Math.sqrt(-e.translationY) * 2);
})
.onEnd((e) => {
if (e.translationY > height.get() * 0.28 || e.velocityY > 900) {
scheduleOnRN(onClose);
} else {
drag.set(withSpring(0, spring('bouncy', reduced)));
}
});
const scrim = useAnimatedStyle(() => ({
opacity: t.get() * (1 - Math.min(1, Math.max(0, drag.get()) / Math.max(1, height.get()))),
}));
const sheet = useAnimatedStyle(() => ({
transform: [{ translateY: interpolate(t.get(), [0, 1], [height.get() + 40, 0]) + drag.get() }],
}));
return (
<Modal
visible={mounted}
transparent
animationType="none"
onRequestClose={onClose}
statusBarTranslucent
navigationBarTranslucent
>
{/* A modal is its own window on Android, so gestures need their own root. */}
<GestureHandlerRootView style={styles.fill}>
<View style={styles.root} onAccessibilityEscape={onClose}>
<Animated.View
style={[StyleSheet.absoluteFill, { backgroundColor: colors.scrim }, scrim]}
>
<Pressable
style={StyleSheet.absoluteFill}
onPress={onClose}
accessibilityRole="button"
accessibilityLabel="Close"
/>
</Animated.View>
<GestureDetector gesture={pan}>
<Animated.View
collapsable={false}
testID={testID}
onLayout={(e) => height.set(e.nativeEvent.layout.height)}
style={[
styles.sheet,
{ paddingBottom: MARGIN + (insets?.bottom ?? 0), gap: 8 },
style,
sheet,
]}
>
<View
style={[
styles.card,
{
backgroundColor: colors.surfaceRaised,
borderRadius: radius.xl,
boxShadow: shadow.lg,
},
]}
>
<View style={[styles.grabber, { backgroundColor: colors.borderStrong }]} />
{title || message ? (
<View style={[styles.head, { borderBottomColor: colors.border }]}>
{title ? (
<Text
accessibilityRole="header"
style={[
styles.center,
{
color: colors.textMuted,
fontSize: font.size.sm,
fontWeight: font.weight.semibold,
},
]}
>
{title}
</Text>
) : null}
{message ? (
<Text
style={[
styles.center,
{ color: colors.textSubtle, fontSize: font.size.xs },
]}
>
{message}
</Text>
) : null}
</View>
) : null}
{actions.map((a, i) => (
<Pressable
key={a.label}
onPress={() => choose(a)}
disabled={a.disabled}
accessibilityRole="button"
accessibilityLabel={a.label}
accessibilityState={{ disabled: a.disabled }}
style={({ pressed }) => [
styles.row,
i > 0 && {
borderTopColor: colors.border,
borderTopWidth: StyleSheet.hairlineWidth,
},
{
backgroundColor: pressed ? colors.surfaceSunken : undefined,
opacity: a.disabled ? 0.4 : 1,
},
]}
>
<Text
style={{
color: a.tone === 'danger' ? colors.danger : colors.text,
fontSize: font.size.lg,
}}
>
{a.label}
</Text>
</Pressable>
))}
</View>
<Pressable
onPress={onClose}
accessibilityRole="button"
accessibilityLabel={cancelLabel}
style={({ pressed }) => [
styles.row,
styles.card,
{
backgroundColor: pressed ? colors.surfaceSunken : colors.surfaceRaised,
borderRadius: radius.xl,
},
]}
>
<Text
style={{
color: colors.text,
fontSize: font.size.lg,
fontWeight: font.weight.semibold,
}}
>
{cancelLabel}
</Text>
</Pressable>
</Animated.View>
</GestureDetector>
</View>
</GestureHandlerRootView>
</Modal>
);
}
const styles = StyleSheet.create({
fill: { flex: 1 },
root: { flex: 1, justifyContent: 'flex-end' },
sheet: { paddingHorizontal: MARGIN },
card: { overflow: 'hidden' },
grabber: {
alignSelf: 'center',
width: 36,
height: 4,
borderRadius: 2,
marginTop: 8,
marginBottom: 2,
},
head: {
paddingHorizontal: 20,
paddingTop: 8,
paddingBottom: 14,
gap: 4,
borderBottomWidth: StyleSheet.hairlineWidth,
},
center: { textAlign: 'center' },
row: { height: ROW, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 20 },
});