@curious-cat-consulting/react-native-swipeable-list
v0.1.0
Published
iOS 26 Mail-style swipeable list row for React Native and Expo
Maintainers
Readme
react-native-swipeable-list
A drop-in FlashList plus a coordinated
SwipeableRow
that gives you iOS Mail-style "only one row open at a time" behavior for free.
Install
npx expo install @curious-cat-consulting/react-native-swipeable-listThen add the peer dependencies your app doesn't already have:
npx expo install @shopify/flash-list react-native-gesture-handler react-native-reanimatedThis library also expects react, react-native, and react-native-worklets at
the versions listed in package.json.
Quick start
Use SwipeableFlashList like FlashList. It wraps each item in a SwipeableRow
for you — just pass renderTrailingActions (and/or renderLeadingActions):
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { SwipeableFlashList } from '@curious-cat-consulting/react-native-swipeable-list';
type Item = { id: string; title: string };
const items: Item[] = [
{ id: '1', title: 'Inbox' },
{ id: '2', title: 'Sent' },
];
export function MailList() {
return (
<SwipeableFlashList
data={items}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View style={styles.row}>
<Text>{item.title}</Text>
</View>
)}
renderTrailingActions={({ item }) => (
<Pressable style={styles.delete} onPress={() => console.log('delete', item.id)}>
<Text style={styles.deleteLabel}>Delete</Text>
</Pressable>
)}
/>
);
}
const styles = StyleSheet.create({
row: { padding: 16, backgroundColor: '#fff' },
delete: {
backgroundColor: '#ff3b30',
justifyContent: 'center',
paddingHorizontal: 24,
},
deleteLabel: { color: '#fff', fontWeight: '600' },
});That's the whole integration: SwipeableFlashList wraps a FlashList in a
coordination provider and wraps each item in a SwipeableRow that registers
with that provider, so opening one row closes the others. The action renderers
receive the row's renderItem info first, so actions can vary per item.
Need finer control? Render SwipeableRow yourself inside renderItem instead
of passing the action props.
Action buttons
Instead of hand-rolling the revealed buttons, drop in SwipeActionCircle — a
44pt circular button styled after iOS 26's Mail swipe actions, which scales and
staggers into place as the row opens (no animation wiring needed):
import { SwipeableFlashList, SwipeActionCircle } from '@curious-cat-consulting/react-native-swipeable-list';
<SwipeableFlashList
data={items}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <Row item={item} />}
renderTrailingActions={({ item }) => [
<SwipeActionCircle
icon="archivebox.fill"
label="Archive"
color="#34c759"
onPress={() => archive(item.id)}
/>,
<SwipeActionCircle icon="trash.fill" label="Delete" onPress={() => remove(item.id)} />,
]}
/>;icon accepts an SF Symbol name (a string), a platform map
({ ios: 'square.and.arrow.up', android: 'share' } for cross-platform rendering
via expo-symbols), or a
render function ({ tintColor, size }) => ReactNode for a custom icon.
Passing a render function means your app doesn't depend on expo-symbols at all:
import { Ionicons } from '@expo/vector-icons';
<SwipeActionCircle
icon={({ tintColor, size }) => <Ionicons name="trash" color={tintColor} size={size} />}
label="Delete"
onPress={() => remove(item.id)}
/>;Tapping a circle does what iOS does when you take a swipe action: a light
haptic fires, your onPress runs, and the row closes. Both extras are on by
default and independently opt-out-able — noHaptic to stay silent, noClose to
leave the row open (e.g. an in-place toggle). The haptic uses expo-haptics,
already a peer dependency.
See src/action/README.md for the full prop list.
Pull to commit (iOS full-swipe)
Drag a row far enough past open and a confirming haptic fires — the iOS "let
go now" cue. Release there and the edge action (the outermost one) runs as if
tapped; ease back under the line first and a lighter reset haptic says it
won't. It's on automatically for any row with actions — nothing to wire up — and
the commit point is tunable (commitTokens.threshold, default an 85%-of-open
overdrag). See src/row/README.md.
What each piece does
| Component | Role |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| SwipeableFlashList | Drop-in FlashList. Forwards every FlashList prop and its ref, auto-wraps each item in a SwipeableRow, and adds allowMultipleOpen plus renderLeadingActions/renderTrailingActions. |
| SwipeableRow | Drop-in ReanimatedSwipeable. Forwards every swipeable prop. Participates in list coordination when inside a SwipeableFlashList. Use directly only when you need finer control than the list props give. |
SwipeableFlashListProps<T> is FlashListProps<T> plus:
| Prop | Default | Description |
| ----------------------- | ------- | ------------------------------------------------------------------------ |
| allowMultipleOpen | false | When false, opening a row closes any other open row. |
| renderLeadingActions | — | Actions revealed by swiping a row from the leading edge (left in LTR). |
| renderTrailingActions | — | Actions revealed by swiping a row from the trailing edge (right in LTR). |
SwipeableRowProps mirrors gesture-handler's SwipeableProps — see their
ReanimatedSwipeable docs
for thresholds, callbacks, and the rest — except renderLeftActions/renderRightActions
are renamed to renderLeadingActions/renderTrailingActions.
Imperative control
SwipeableFlashList's ref is a normal FlashListRef<T> — scrollToIndex, etc. work
unchanged.
SwipeableRow's ref exposes a small handle for closing a row:
import { useRef } from 'react';
import { SwipeableRow, type SwipeableRowHandle } from '@curious-cat-consulting/react-native-swipeable-list';
const rowRef = useRef<SwipeableRowHandle>(null);
rowRef.current?.close();Advanced: custom lists
If you aren't using FlashList, wrap your own list in SwipeableListProvider and
render SwipeableRows inside it. See src/context/README.md.
SwipeableRow also works standalone outside any provider — it behaves exactly like
ReanimatedSwipeable with no coordination.
Further reading
src/list/README.md—SwipeableFlashListdetailssrc/row/README.md—SwipeableRowdetailssrc/context/README.md— coordination layersrc/action/README.md—SwipeActionCircleand the action layer
