SwipeableListItem
Swipeable list item that reveals action buttons on left or right swipe. Like iOS Mail.
Design Review
Review the new dashboard mockups
Team Standup
Daily sync with the engineering team
Bug Report #142
Fix navigation crash on Android
Release Notes
Draft v2.0 changelog for review
Web preview — components render natively on iOS & Android
import { SwipeableListItem } from "@/components/ui/swipeable-list-item";
import { ListItem, ListItemTitle, ListItemDescription } from "@/components/ui/list";
export function InboxScreen() {
return (
<SwipeableListItem
rightActions={[
{ key: "archive", label: "Archive", color: "bg-amber-500", onPress: () => {} },
{ key: "delete", label: "Delete", color: "bg-destructive", onPress: () => {} },
]}
leftActions={[
{ key: "pin", label: "Pin", color: "bg-green-600", onPress: () => {} },
]}
>
<ListItem>
<View className="flex-1">
<ListItemTitle>Design Review</ListItemTitle>
<ListItemDescription>Review the new onboarding flow</ListItemDescription>
</View>
</ListItem>
</SwipeableListItem>
);
}Installation#
npx @aniui/cli add swipeable-list-itemThis component requires additional dependencies:
npx expo install react-native-reanimated react-native-gesture-handlerYou also need to wrap your app with GestureHandlerRootView from react-native-gesture-handler.
Usage#
app/inbox.tsx
import { SwipeableListItem } from "@/components/ui/swipeable-list-item";
import { ListItem, ListItemTitle, ListItemDescription } from "@/components/ui/list";
export function InboxScreen() {
return (
<SwipeableListItem
rightActions={[
{ key: "archive", label: "Archive", color: "bg-amber-500", onPress: () => {} },
{ key: "delete", label: "Delete", color: "bg-destructive", onPress: () => {} },
]}
leftActions={[
{ key: "pin", label: "Pin", color: "bg-green-600", onPress: () => {} },
]}
>
<ListItem>
<View className="flex-1">
<ListItemTitle>Design Review</ListItemTitle>
<ListItemDescription>Review the new onboarding flow</ListItemDescription>
</View>
</ListItem>
</SwipeableListItem>
);
}Right Actions Only#
Swipe left to reveal a single delete action — the most common pattern.
<SwipeableListItem
rightActions={[
{
key: "delete",
label: "Delete",
color: "bg-destructive",
onPress: () => handleDelete(item.id),
},
]}
>
<ListItem>
<ListItemTitle>{item.title}</ListItemTitle>
</ListItem>
</SwipeableListItem>With Icons#
Add an icon to each action for a richer look. Icons render above the label.
import { Text } from "react-native";
<SwipeableListItem
rightActions={[
{
key: "archive",
label: "Archive",
color: "bg-amber-500",
icon: <Text className="text-white text-lg">📦</Text>,
onPress: () => archiveItem(id),
},
{
key: "delete",
label: "Delete",
color: "bg-destructive",
icon: <Text className="text-white text-lg">🗑️</Text>,
onPress: () => deleteItem(id),
},
]}
>
{children}
</SwipeableListItem>Inside FlatList#
Works seamlessly inside FlatList and ScrollView. The gesture uses activeOffsetX to avoid stealing vertical scrolls.
import { FlatList } from "react-native";
import { SwipeableListItem } from "@/components/ui/swipeable-list-item";
import { ListItem, ListItemTitle } from "@/components/ui/list";
const actions = [
{ key: "delete", label: "Delete", color: "bg-destructive", onPress: () => {} },
];
export function MessageList({ messages }) {
return (
<FlatList
data={messages}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<SwipeableListItem
rightActions={actions.map((a) => ({
...a,
onPress: () => handleDelete(item.id),
}))}
>
<ListItem>
<ListItemTitle>{item.text}</ListItemTitle>
</ListItem>
</SwipeableListItem>
)}
/>
);
}Props#
SwipeableListItemProps#
PropTypeDefault
childrenReact.ReactNoderequiredleftActionsSwipeableAction[][]rightActionsSwipeableAction[][]onSwipeOpen(direction: "left" | "right") => void—enabledbooleantrueclassNamestring—SwipeableAction#
PropTypeDefault
keystringrequiredlabelstringrequirediconReact.ReactNode—colorstringrequiredtextColorstring"text-white"onPress() => voidrequiredAccessibility#
- Gesture-based swipe with action buttons revealed on swipe.
- Action buttons have
accessibilityRole="button"for screen reader users who cannot swipe.
Source#
components/ui/swipeable-list-item.tsx
import React, { useCallback } from "react";
import { View, Text, Pressable } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, { useSharedValue, useAnimatedStyle, withSpring, runOnJS } from "react-native-reanimated";
import { cn } from "@/lib/utils";
import { springs } from "@/components/ui/animate";
const ACTION_WIDTH = 80;
export interface SwipeableAction {
key: string;
label: string;
icon?: React.ReactNode;
color: string;
textColor?: string;
onPress: () => void;
}
export interface SwipeableListItemProps extends React.ComponentPropsWithoutRef<typeof View> {
children: React.ReactNode;
leftActions?: SwipeableAction[];
rightActions?: SwipeableAction[];
onSwipeOpen?: (direction: "left" | "right") => void;
enabled?: boolean;
className?: string;
}
function ActionTray({ actions, side }: { actions: SwipeableAction[]; side: "left" | "right" }) {
return (
<View
style={{
position: "absolute",
top: 0,
bottom: 0,
...(side === "left" ? { left: 0 } : { right: 0 }),
width: actions.length * ACTION_WIDTH,
flexDirection: "row",
}}
>
{actions.map((action) => (
<Pressable
key={action.key}
onPress={action.onPress}
className={cn("items-center justify-center", action.color)}
style={{ width: ACTION_WIDTH }}
accessible={true}
accessibilityRole="button"
accessibilityLabel={action.label}
>
{action.icon}
<Text className={cn("text-xs font-medium mt-1", action.textColor ?? "text-white")}>
{action.label}
</Text>
</Pressable>
))}
</View>
);
}
export function SwipeableListItem({
children,
leftActions = [],
rightActions = [],
onSwipeOpen,
enabled = true,
className,
...props
}: SwipeableListItemProps) {
const translateX = useSharedValue(0);
const startX = useSharedValue(0);
const leftWidth = leftActions.length * ACTION_WIDTH;
const rightWidth = rightActions.length * ACTION_WIDTH;
const notifyOpen = useCallback(
(dir: "left" | "right") => onSwipeOpen?.(dir),
[onSwipeOpen]
);
const pan = Gesture.Pan()
.activeOffsetX([-10, 10])
.enabled(enabled)
.onStart(() => { startX.value = translateX.value; })
.onUpdate((e) => {
translateX.value = Math.max(-rightWidth, Math.min(leftWidth, startX.value + e.translationX));
})
.onEnd((e) => {
const x = translateX.value;
if (x > 0 && leftWidth > 0) {
const open = x > leftWidth * 0.5 || e.velocityX > 500;
translateX.value = withSpring(open ? leftWidth : 0, springs.snappy);
if (open) runOnJS(notifyOpen)("left");
} else if (x < 0 && rightWidth > 0) {
const open = Math.abs(x) > rightWidth * 0.5 || e.velocityX < -500;
translateX.value = withSpring(open ? -rightWidth : 0, springs.snappy);
if (open) runOnJS(notifyOpen)("right");
} else {
translateX.value = withSpring(0, springs.snappy);
}
});
const contentStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
return (
<View className={cn("overflow-hidden", className)} {...props}>
{leftActions.length > 0 && <ActionTray actions={leftActions} side="left" />}
{rightActions.length > 0 && <ActionTray actions={rightActions} side="right" />}
<GestureDetector gesture={pan}>
<Animated.View style={contentStyle} className="bg-background">
{children}
</Animated.View>
</GestureDetector>
</View>
);
}

