gesture-recipes
v0.1.0-rc.0
Published
Production-grade React Native gesture patterns with the edge cases debugged once.
Maintainers
Readme
gesture-recipes
Feels like Apple Mail. Behaves like Gmail. Drop-in for React Native.
A production-grade <SwipeToDelete> component for React Native. Handles the gesture mechanics, the spring feel, the accessibility, the optimistic/undoable flow — so you don't.
Install
npm install gesture-recipes react-native-gesture-handler react-native-reanimated
# optional, for haptics:
npm install expo-haptics
# or
npm install react-native-haptic-feedbackUsage
import { SwipeToDelete } from 'gesture-recipes';
<SwipeToDelete onDelete={() => removeMessage(id)}>
<MessageRow message={msg} />
</SwipeToDelete>Gmail-style undo
<SwipeToDelete
behavior="undoable"
undoWindowMs={5000}
onDelete={() => api.delete(id)}
>
<MessageRow message={msg} />
</SwipeToDelete>Swipe → row collapses with animation, Undo toast appears for 5 seconds, API call fires only after the window closes (or never, if the user undoes).
Optimistic update with auto-rollback
<SwipeToDelete
behavior="optimistic"
onDelete={async () => api.delete(id)} // returns Promise; on rejection, row springs back
>
<Row />
</SwipeToDelete>Custom actions + list coordination
import { SwipeProvider, SwipeToDelete, useSwipeContext } from 'gesture-recipes';
function List() {
const ctx = useSwipeContext();
return (
<FlatList
data={items}
onScrollBeginDrag={() => ctx?.notifyScroll()}
renderItem={({ item }) => (
<SwipeToDelete
autoCloseMs={5000}
rightActions={[
{ id: 'archive', icon: 'archive', label: 'Archive', color: '#007AFF', onPress: () => archive(item.id) },
{ id: 'delete', icon: 'trash', label: 'Delete', destructive: true, onPress: () => remove(item.id) },
]}
>
<MessageRow message={item} />
</SwipeToDelete>
)}
/>
);
}
export default function App() {
return <SwipeProvider><List /></SwipeProvider>;
}What it handles
| Production swipe-to-delete needs | gesture-recipes |
|---|---|
| Don't fight vertical scroll | activeOffsetX: [-10, 10] |
| Fast flicks should open | velocity check at release |
| Adapt to system font scale | per-render onLayout measurement |
| RTL locales (Arabic, Hebrew) | I18nManager.isRTL semantic flip |
| Opened rows close on parent scroll | SwipeProvider + onScrollBeginDrag |
| Accessibility: VoiceOver/TalkBack | accessibilityLabel + accessibilityRole="button" per action |
| Accessibility: rotor actions | accessibilityActions on the row |
| Honor "Reduce Motion" OS setting | AccessibilityInfo.isReduceMotionEnabled → instant snap |
| Animated row collapse on delete | built-in (scaleY + opacity) |
| Optimistic delete + auto-rollback | behavior="optimistic" |
| Undo toast window | behavior="undoable" + renderUndoToast slot |
| Auto-close opened row when idle | autoCloseMs prop |
| Calibrated spring feel | iPhone 15 Mail.app comparison documented |
API
<SwipeToDelete> props
children— row contentonDelete?— shorthand for a single destructive right actionrightActions?— actions revealed by swiping LEFT (or RIGHT in RTL)leftActions?— actions revealed by swiping RIGHT (or LEFT in RTL)behavior?—'direct' | 'optimistic' | 'undoable'(default'direct')undoWindowMs?— undo window in ms (default 5000)renderUndoToast?— custom toast renderer; receives{ onUndo, secondsLeft, label }autoCloseMs?— auto-close opened row after this idle time (default 0 = off)threshold?— fraction of action-zone width past which release auto-opens (default 0.5)velocityThreshold?— px/s threshold for flick-to-open (platform default: iOS 500, Android 600)haptics?— fire haptic at threshold cross (default true)closeOnScroll?— close on parent SwipeProvider scroll (default true)closeOnAction?— close after action's onPress resolves (default true)appearance?—'default' | 'minimal'(default'default')onOpenChange?— fires on full open or full close
<SwipeProvider>
Wrap your list. Call useSwipeContext().notifyScroll() from the list's onScrollBeginDrag. Coordinates "only one row open at a time" + close-on-scroll.
useSwipeToDelete(options) (escape hatch)
Returns { gestureHandler, animatedStyle, isOpen, close } for custom row layouts.
Behaviors compared
| Mode | When to use | What happens |
|---|---|---|
| direct | Low-stakes, recoverable (toggle, mute) | Await onPress, close on success, stay open on rejection |
| optimistic | API generally reliable, snappy UX preferred | Row collapses immediately, API runs in background, rollback on rejection |
| undoable | Destructive but recoverable (archive, delete with restore) | Row collapses + Undo toast for undoWindowMs, API fires after timeout |
Known limitations (v0.1)
- Programmatic open via ref is not exposed (planned for v0.2).
- Nested swipeables behavior is undefined (don't nest).
- Web is not supported.
- The default UndoToast renders inside the row container (positioned
absolute). For a global snackbar, pass your ownrenderUndoToast. - Row collapse uses
scaleY+ opacity, which is purely visual. To remove the row from the layout (and reflow siblings), the caller must remove the item from the data source — typically inside the asynconDeletebody or after.
Calibration
The spring config (damping: 25, stiffness: 250, mass: 0.6 on iOS; damping: 22 on Android) was tuned by side-by-side comparison against Apple Mail.app on iPhone 15 and against Gmail on Pixel 7. Re-calibration procedure documented at e2e/manual/animation-feel-spring.md.
License
MIT
