Event Handling & Touchables
Pressable, gestures at the primitive layer, and hitSlop patterns.
Search across all documentation pages
Pressable, gestures at the primitive layer, and hitSlop patterns.
Quick-reference recipe card - copy-paste ready.
import { Pressable, StyleSheet, Text, View } from "react-native";
interface IconButtonProps {
label: string;
onPress: () => void;
disabled?: boolean;
}
export function IconButton({ label, onPress, disabled = false }: IconButtonProps) {
return (
<Pressable
onPress={onPress}
disabled={disabled}
hitSlop={12}
accessibilityRole="button"
accessibilityState={{ disabled }}
style={({ pressed }) => [
styles.button,
pressed && !disabled && styles.pressed,
disabled && styles.disabled,
]}
android_ripple={{ color: "rgba(255,255,255,0.25)", borderless: false }}
>
<Text style={styles.label}>{label}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
button: {
minHeight: 44,
minWidth: 44,
paddingHorizontal: 16,
borderRadius: 10,
backgroundColor: "#2563eb",
alignItems: "center",
justifyContent: "center",
},
pressed: { opacity: 0.85 },
disabled: { opacity: 0.4 },
label: { color: "#fff", fontWeight: "600", fontSize: 15 },
});When to reach for this: Any tappable control - buttons, list rows, chips - where you need reliable presses, accessible targets, and platform-appropriate feedback.
import { useState } from "react";
import {
Platform,
Pressable,
StyleSheet,
Text,
View,
} from "react-native";
type Action = "save" | "share" | "delete";
const ACTIONS: { key: Action; label: string; destructive?: boolean }[] = [
{ key: "save", label: "Save draft" },
{ key: "share", label: "Share link" },
{ key: "delete", label: "Delete", destructive: true },
];
export default function ActionSheetDemo() {
const [lastEvent, setLastEvent] = useState("Tap an action");
const [pressedKey, setPressedKey] = useState<Action | null>(null);
return (
<View style={styles.screen}>
<Text style={styles.title}>Touch targets</Text>
<Text style={styles.subtitle}>{lastEvent}</Text>
<View style={styles.sheet}>
{ACTIONS.map((action) => (
<Pressable
key={action.key}
onPress={() => setLastEvent(`onPress: ${action.label}`)}
onPressIn={() => setPressedKey(action.key)}
onPressOut={() => setPressedKey(null)}
onLongPress={() => setLastEvent(`onLongPress: ${action.label}`)}
delayLongPress={400}
hitSlop={{ top: 6, bottom: 6, left: 4, right: 4 }}
accessibilityRole="button"
accessibilityHint={
action.destructive ? "Permanently removes the item" : undefined
}
style={({ pressed }) => [
styles.row,
pressed && styles.rowPressed,
action.destructive && styles.rowDestructive,
]}
android_ripple={{
color: action.destructive ? "rgba(239,68,68,0.2)" : "rgba(37,99,235,0.15)",
}}
>
<Text
style={[
styles.rowLabel,
action.destructive && styles.destructiveText,
pressedKey === action.key && styles.rowLabelPressed,
]}
>
{action.label}
</Text>
<Text style={styles.chevron}>›</Text>
</Pressable>
))}
</View>
<View style={styles.toolbar}>
<Pressable
onPress={() => setLastEvent("Toolbar icon tapped")}
hitSlop={16}
style={({ pressed }) => [styles.iconHit, pressed && styles.iconHitPressed]}
>
<View style={styles.iconGlyph} />
</Pressable>
<Text style={styles.toolbarHint}>
Icon is 24×24 with 16pt hitSlop → 56×56 effective target
</Text>
</View>
{Platform.OS === "ios" && (
<Text style={styles.note}>
iOS: opacity feedback; Android: ripple via android_ripple
</Text>
)}
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 20, backgroundColor: "#f8fafc", gap: 16 },
title: { fontSize: 22, fontWeight: "700", color: "#0f172a" },
subtitle: { fontSize: 15, color: "#475569" },
sheet: {
backgroundColor: "#fff",
borderRadius: 14,
overflow: "hidden",
borderWidth: StyleSheet.hairlineWidth,
borderColor: "#cbd5e1",
},
row: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 14,
minHeight: 48,
},
rowPressed: { backgroundColor: "#f1f5f9" },
rowDestructive: {},
rowLabel: { flex: 1, fontSize: 16, color: "#0f172a" },
rowLabelPressed: { color: "#2563eb" },
destructiveText: { color: "#dc2626" },
chevron: { fontSize: 20, color: "#94a3b8" },
toolbar: { flexDirection: "row", alignItems: "center", gap: 12 },
iconHit: { padding: 4 },
iconHitPressed: { opacity: 0.6 },
iconGlyph: {
width: 24,
height: 24,
borderRadius: 6,
backgroundColor: "#2563eb",
},
toolbarHint: { flex: 1, fontSize: 13, color: "#64748b" },
note: { fontSize: 12, color: "#94a3b8" },
});What this demonstrates:
Pressable style function reacting to pressed state for cross-platform feedbackonPressIn / onPressOut for tracking active presses without firing the full pressonLongPress with delayLongPress for secondary actionshitSlop enlarging small icon targets without changing layout metricsandroid_ripple for Material-style touch ripples on AndroidaccessibilityRole, accessibilityHint, and accessibilityState on interactive elementsPressable tracks press lifecycle states (pressed, hovered, focused) and maps them to styles or children functions.onPress fires on touch release inside the view bounds when the gesture is not cancelled.disabled removes the view from the responder chain for press activation.react-native-gesture-handler instead of raw responder APIs.| Event | Fires when | Typical use |
|---|---|---|
onPressIn | Finger down inside view | Highlight, haptic prep |
onPressOut | Finger up or gesture cancelled | Clear highlight |
onPress | Completed tap (down + up inside) | Primary action |
onLongPress | Held past delayLongPress (default 500ms) | Context menu, delete confirm |
onPress not called | Scroll steals gesture, finger slides outside, parent scrolls | Expected - do not fight scroll |
// Uniform expansion (number = all sides)
<Pressable hitSlop={12} />
// Per-edge control - useful above tab bars or under notches
<Pressable hitSlop={{ top: 8, bottom: 16, left: 12, right: 12 }} />
// Negative hitSlop shrinks the target (rare - overlapping rows)
<Pressable hitSlop={-4} />| Target size | Recommended minimum | Technique |
|---|---|---|
| Text link | 44×44 pt effective | hitSlop + minHeight |
| Toolbar icon 24×24 | 44×44 pt effective | hitSlop={10} or padding wrapper |
| Full-width list row | 48dp height | paddingVertical on Pressable |
| Destructive action | 44×44 + confirmation | onLongPress or second step |
| Component | Feedback | Status |
|---|---|---|
Pressable | Custom opacity, ripple, children fn | Preferred |
TouchableOpacity | Fades opacity on press | Legacy, still common |
TouchableHighlight | Darkens underlay | Legacy |
TouchableWithoutFeedback | None | Avoid for buttons - a11y gaps |
// Primitive layer - sufficient for buttons and list rows
<Pressable onPress={save} />
// Gesture Handler - pan, pinch, simultaneous handlers (install in Expo)
import { Gesture, GestureDetector } from "react-native-gesture-handler";
const pan = Gesture.Pan().onEnd((e) => {
if (e.translationX < -80) archiveItem();
});Pressable until scroll conflicts or multi-touch requirements appear.react-native-gesture-handler integrates with native drivers for 60fps pans.import type { PressableProps, StyleProp, ViewStyle } from "react-native";
import type { GestureResponderEvent } from "react-native";
type ButtonStyle = PressableProps["style"]; // StyleProp<ViewStyle> | fn
interface RowProps {
onPress: (event: GestureResponderEvent) => void;
style?: StyleProp<ViewStyle>;
}
// Style function form
const styleFn: Extract<ButtonStyle, Function> = ({ pressed, hovered }) => [
styles.base,
pressed && styles.pressed,
];PressableProps["style"] covers both static styles and the pressed callback form.GestureResponderEvent types native touch metadata (nativeEvent.locationX).() => void callbacks in props when you do not need the event object.Tiny touch targets - A 20×20 icon without hitSlop fails HIG/Material minimums and frustrates users. Fix: Add hitSlop or wrap with padding to reach ~44×44 pt.
ScrollView steals presses - Horizontal swipes on a Pressable inside ScrollView may cancel onPress. Fix: Tune keyboardShouldPersistTaps, use delayPressIn, or move gesture to Gesture Handler.
TouchableOpacity wrapping large trees - Fading opacity on a huge subtree flashes all children. Fix: Apply feedback on the Pressable row only, not the whole screen wrapper.
Missing disabled feedback - disabled silences onPress but visuals may look active. Fix: Combine disabled style, accessibilityState={{ disabled: true }}, and pointerEvents where needed.
Overlapping pressables fight - Absolute overlays intercept touches unintentionally. Fix: Set pointerEvents="box-none" on decorative parents or pointerEvents="none" on overlays.
Relying on onPress for drag detection - Press fires after release; drag gestures need pan handlers. Fix: Use react-native-gesture-handler Pan for swipe-to-delete.
Android ripple without overflow hidden - Ripples bleed outside rounded cards. Fix: Set overflow: "hidden" on the card and match borderRadius on android_ripple.
| Alternative | Use When | Don't Use When |
|---|---|---|
Pressable | Default buttons, list rows, chips | Complex multi-finger gestures |
TouchableOpacity | Maintaining legacy code quickly | Greenfield - prefer Pressable |
Text onPress | Inline links inside copy | Primary CTAs needing 44pt targets |
react-native-gesture-handler | Swipe rows, drawers, pinch zoom | Simple static buttons |
Native Button | System-styled trivial actions | Custom brand styles |
expo-haptics + Pressable | Tactile confirmation on success | Every tap - causes haptic fatigue |
Pressable exposes pressed, hovered, and focused states in a style function.android_ripple, hitSlop, and accessibility props are first-class.TouchableOpacity always animates opacity - less control and wider re-render surface on big trees.ScrollView before release.disabled={true} is set on the Pressable.<Pressable
android_ripple={{ color: "rgba(0,0,0,0.12)" }}
style={({ pressed }) => [styles.btn, pressed && styles.btnPressed]}
/>android_ripple is ignored on iOS - safe to ship in cross-platform files.pressed style (typically lower opacity or background change).delayPressIn (default 0) can be raised by parents or ScrollView press retention.onPressIn blocks the next frame.onPress or requestAnimationFrame; show highlight immediately.onLongPress fires (default 500).onPress still fires on quick taps unless long-press handler consumes the gesture.auto (default) - view and children receive touches.none - view and children are transparent to touches.box-none - view ignores touches; children still receive them.box-only - view receives touches; children do not.Pressable for new code - clearer state and ripple control.TouchableHighlight underlay can flash incorrectly in virtualized lists during fast scroll.onPress closures per item.const [submitting, setSubmitting] = useState(false);
const onSubmit = async () => {
if (submitting) return;
setSubmitting(true);
try {
await save();
} finally {
setSubmitting(false);
}
};
<Pressable onPress={onSubmit} disabled={submitting} />Pressable row with child Text nodes.accessibilityRole="button" marks the element for screen readers.accessibilityLabel when visible text is insufficient (icon-only).accessibilityState={{ disabled, selected, busy }} mirrors visual state.accessibilityHint for destructive or non-obvious outcomes.GestureDetector for advanced cases.Pressable callbacks execute on the JavaScript thread (Hermes).react-native-reanimated can stay on the UI thread separately.TextInput, keyboardShouldPersistTaps="handled" lets taps on Pressable fire without dismissing the keyboard first.hitSlop - both solve different problems.hitSlop.Pressable props; tune when users report "slip off" cancellations.onPress handlersStack versions: This page was written for React 19.2.3, React Native 0.86.0, and Expo SDK 57 (
expo~57.0.4).
Reviewed by Chris St. John·Last updated Jul 16, 2026