Swipe actions
Swipe a row sideways to reveal the actions behind it.
Draft
Specified, not implemented yet. The API is described in useSwipeActions and may change.
The user drags a row sideways. The row follows the finger and uncovers buttons underneath: archive, flag, mark as read. Released far enough, it stays open on the buttons; otherwise it closes.
Drag a row left for Flag and Archive, or right for Read. Try a short, fast flick: speed counts as much as distance. Tap anywhere else to close the open row.
The same row, frame by frame:
At rest. The actions wait under the row.
Follows the finger. Released here, it closes.
Past half, or a fast flick: snaps open.
Past the actions, it resists. The buttons stretch.
The other way reveals the leading action.
When to use it
- Lists where the user triages many items in a row: mail, notifications, tasks, conversations.
- Two or three frequent actions per row, at most on each side.
Swipe actions are invisible until someone tries. Keep every action reachable another way: a long press menu, the detail screen, or selection mode. Don’t use them on rows that already react to a horizontal gesture (a carousel, a slider), or in a screen that is itself swiped between tabs.
How it works
translateX is how far the row is from its rest position: negative to reveal the trailing actions, positive for the leading ones.
While dragging
| Rule | Why |
|---|---|
| Direction lock after 8pt: horizontal goes to the row, vertical to the list | A slightly diagonal scroll doesn’t move rows. |
| 1:1 up to the actions’ width | The row sticks to the finger. |
| Resistance past it, the buttons stretch to fill the gap | The user feels the end without a hard stop. |
| One open row: starting a swipe closes the other one | The list never has two rows half-open. |
Past the actions’ width w, each point counts less:
x = w + (over * resistance) / (over + resistance); // resistance: 60ptOn release
The row opens or closes from velocity first, then position:
The snap is a spring that starts from the release velocity, so a flick carries on smoothly instead of restarting.
Once open
- A tap on the row, on another row or on the screen closes it without triggering anything else.
- A tap on an action runs it and closes the row. If the action removes the row (archive, delete), the row slides out and the list closes the gap.
- A scroll of the list closes it.
Full swipe
A side can trigger its outermost action with a long swipe, without stopping on the buttons. It suits the one action the user repeats most, often Archive or Delete.
Swipe a row a little, then all the way. What happens after the row leaves, with the snackbar and Undo, is described in undoable action.
Short swipe: the Delete button stays open.
Past 55%: haptic, the button fills the row.
Released: slides out, collapses, snackbar.
Gone from the list. Nothing sent yet.
Timeout: the delete is sent. Undo: the row comes back.
progress is |translateX| / rowWidth. Two thresholds split the swipe:
| Zone | Release does | Feedback |
|---|---|---|
| Under half the button (40pt) | Closes. | – |
| Button to 55% of the row | Opens on the buttons. | – |
| Past 55% (armed) | Runs the outermost action. | Haptic when crossing, both ways. The button fills the row, its icon follows the row’s edge. |
Crossing back under 55% before releasing disarms it: the user can change their mind until the last moment.
Only use a full swipe for an action that can be undone. Deleting something that can’t be restored (a folder with its content, an account) needs a Dialog first.
Implementation
Axiom implements it with one hook, useSwipeActions, and a SwipeableRow that renders the actions behind any Item.
| Library | Used for |
|---|---|
react-native-gesture-handler | Gesture.Pan() with activeOffsetX([-8, 8]) and failOffsetY([-8, 8]): the direction lock, without blocking the list’s vertical scroll. |
react-native-reanimated | translateX as a shared value, the resistance and the action widths computed in the gesture callbacks. withSpring(target, { velocity }) for the snap. progress and the armed state are derived on the UI thread; runOnJS fires the haptic when it changes. |
| Reanimated layout animations | exiting slides a removed row out, LinearTransition on the list closes the gap. |
| React context | The id of the open row, shared by the rows of one list, so opening a row closes the other. |
In a screen
<SwipeableList>
<FlatList
data={mails}
renderItem={({ item }) => (
<SwipeableRow
leading={[
{
key: "read",
icon: "mail",
label: "Read",
color: "info",
onPress: () => toggleRead(item),
},
]}
trailing={[
{
key: "flag",
icon: "flag",
label: "Flag",
color: "warning",
onPress: () => flag(item),
},
{
key: "archive",
icon: "archive",
label: "Archive",
fullSwipe: true,
onPress: () => archive(item),
},
]}
>
<Item onPress={() => open(item)}>…</Item>
</SwipeableRow>
)}
/>
</SwipeableList>Options and return value are documented in useSwipeActions.
Accessibility
- Expose each action as an
accessibilityActionsentry on the row: VoiceOver lists them in the rotor, TalkBack in its actions menu. Screen reader users never swipe rows. - Action buttons are hidden from screen readers while the row is closed, and focusable when it’s open.
- Colors aren’t enough: each action has an icon and a label. The haptic isn’t the only armed signal: the button changes shape too.
- With Reduce Motion on, the snap is a short timing instead of a spring, and a removed row disappears without sliding.