SwipeableListItem
Swipeable list item that reveals action buttons on left or right swipe. Like iOS Mail.
Design Review
Review the new onboarding flow mockups
Team Standup
Daily sync at 10:00 AM
Deploy v2.1
Push release to production
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";
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
className={\`absolute \${side}-0 top-0 bottom-0 flex-row\`}
style={{ width: actions.length * ACTION_WIDTH }}
>
{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);
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);
if (open) runOnJS(notifyOpen)("right");
} else {
translateX.value = withSpring(0);
}
});
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>
);
}