AniUI

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-item

This component requires additional dependencies:

npx expo install react-native-reanimated react-native-gesture-handler

You 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
children
React.ReactNode
required
leftActions
SwipeableAction[]
[]
rightActions
SwipeableAction[]
[]
onSwipeOpen
(direction: "left" | "right") => void
enabled
boolean
true
className
string

SwipeableAction#

PropTypeDefault
key
string
required
label
string
required
icon
React.ReactNode
color
string
required
textColor
string
"text-white"
onPress
() => void
required

Accessibility#

  • 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>
  );
}