Gesture
Swipeable List
Rows reveal contextual actions with a horizontal spring gesture
Expo code
Original web component
Be UIOur Expo component
React Native WebComponent source
Make it yours
One file to copy into your app.
swipeable-list.tsx
import React, { useEffect, useState } from "react"
import { Pressable, Text, View } from "react-native"
import { Gesture, GestureDetector } from "react-native-gesture-handler"
import Animated, {
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withSpring,
} from "react-native-reanimated"
import { scheduleOnRN } from "react-native-worklets"
export interface SwipeableListAction {
id: string
label: string
icon?: React.ReactNode
tone?: "default" | "primary" | "success" | "warning" | "danger"
disabled?: boolean
onClick?: () => void
}
export interface SwipeableListItem {
id: string
title: React.ReactNode
description?: React.ReactNode
meta?: React.ReactNode
leading?: React.ReactNode
content?: React.ReactNode
leftActions?: SwipeableListAction[]
rightActions?: SwipeableListAction[]
}
export type SwipeableListOpen = { id: string; side: "left" | "right" } | null
export interface SwipeableListProps {
items: SwipeableListItem[]
open?: SwipeableListOpen
defaultOpen?: SwipeableListOpen
onOpenChange?: (open: SwipeableListOpen) => void
onAction?: (payload: {
item: SwipeableListItem
action: SwipeableListAction
side: "left" | "right"
}) => void
actionWidth?: number
revealThreshold?: number
closeOnAction?: boolean
renderItem?: (item: SwipeableListItem) => React.ReactNode
backgroundColor?: string
textColor?: string
mutedColor?: string
radius?: number
testID?: string
}
const tones = {
default: "#888",
primary: "#f1f1f1",
success: "#34d399",
warning: "#fbbf24",
danger: "#ef5b5b",
}
function Row({
item,
open,
change,
onAction,
actionWidth,
threshold,
closeOnAction,
renderItem,
backgroundColor,
textColor,
mutedColor,
radius,
}: {
item: SwipeableListItem
open: SwipeableListOpen
change: (value: SwipeableListOpen) => void
onAction?: SwipeableListProps["onAction"]
actionWidth: number
threshold: number
closeOnAction: boolean
renderItem?: SwipeableListProps["renderItem"]
backgroundColor: string
textColor: string
mutedColor: string
radius: number
}) {
const left = (item.leftActions?.length ?? 0) * actionWidth,
right = (item.rightActions?.length ?? 0) * actionWidth
const x = useSharedValue(0),
start = useSharedValue(0),
reduced = useReducedMotion()
const spring = { stiffness: 560, damping: 48, mass: 0.82 }
const target =
open?.id === item.id ? (open.side === "left" ? left : -right) : 0
useEffect(() => {
x.value = reduced ? target : withSpring(target, spring)
}, [target, reduced, x])
function commit(position: number) {
change(
position > 0
? { id: item.id, side: "left" }
: position < 0
? { id: item.id, side: "right" }
: null
)
}
const pan = Gesture.Pan()
.activeOffsetX([-10, 10])
.failOffsetY([-12, 12])
.onStart(() => {
start.value = x.value
})
.onUpdate((e) => {
const value = start.value + e.translationX
x.value =
value > left
? left + (value - left) * 0.04
: value < -right
? -right + (value + right) * 0.04
: value
})
.onEnd((e) => {
const velocity = Math.max(-1500, Math.min(1500, e.velocityX))
const alreadyLeft = open?.id === item.id && open.side === "left"
const alreadyRight = open?.id === item.id && open.side === "right"
const leftOpen = left > 0 && (alreadyLeft
? x.value >= left * 0.72 && velocity >= -320
: x.value > Math.max(threshold, left * 0.46) || velocity > 720 && x.value > 14)
const rightOpen = right > 0 && (alreadyRight
? Math.abs(x.value) >= right * 0.72 && velocity <= 320
: x.value < -Math.max(threshold, right * 0.46) || velocity < -720 && x.value < -14)
const next = leftOpen ? left : rightOpen ? -right : 0
x.value = reduced ? next : withSpring(next, { ...spring, velocity })
scheduleOnRN(commit, next)
})
.onFinalize((_, success) => {
if (!success) x.value = withSpring(target, spring)
})
const slide = useAnimatedStyle(() => ({
transform: [{ translateX: x.value }],
}))
function rail(
actions: SwipeableListAction[] | undefined,
side: "left" | "right"
) {
return (
<View
style={{
position: "absolute",
top: 0,
bottom: 0,
[side]: 0,
flexDirection: "row",
alignItems: "center",
backgroundColor,
}}
>
{actions?.map((action) => (
<Pressable
key={action.id}
accessibilityRole="button"
accessibilityLabel={action.label}
disabled={action.disabled}
onPress={() => {
action.onClick?.()
onAction?.({ item, action, side })
if (closeOnAction) change(null)
}}
style={{
width: actionWidth,
alignItems: "center",
justifyContent: "center",
height: "100%",
opacity: action.disabled ? 0.4 : 1,
}}
>
<View
style={{
width: 36,
height: 36,
borderRadius: 18,
alignItems: "center",
justifyContent: "center",
backgroundColor: "transparent",
}}
>
{action.icon ?? (
<Text
style={{
color: tones[action.tone ?? "default"],
fontSize: 17,
}}
>
{action.label.slice(0, 1)}
</Text>
)}
</View>
</Pressable>
))}
</View>
)
}
return (
<View style={{ overflow: "hidden", borderRadius: radius }}>
{rail(item.leftActions, "left")}
{rail(item.rightActions, "right")}
<GestureDetector gesture={pan}>
<Animated.View
accessible
accessibilityLabel={
typeof item.title === "string" ? item.title : "Swipeable item"
}
accessibilityActions={[
...(left
? [{ name: "revealLeft", label: "Show left actions" }]
: []),
...(right
? [{ name: "revealRight", label: "Show right actions" }]
: []),
{ name: "close", label: "Close actions" },
]}
onAccessibilityAction={(e) =>
commit(
e.nativeEvent.actionName === "revealLeft"
? left
: e.nativeEvent.actionName === "revealRight"
? -right
: 0
)
}
style={[
{
minHeight: 72,
backgroundColor,
paddingHorizontal: 16,
paddingVertical: 12,
flexDirection: "row",
alignItems: "center",
gap: 12,
borderRadius: radius,
borderWidth: 1,
borderColor: "#ffffff0d",
},
slide,
]}
>
{renderItem
? renderItem(item)
: (item.content ?? (
<>
{item.leading}
<View style={{ flex: 1, gap: 2, minWidth: 0 }}>
<Text numberOfLines={1} style={{ color: textColor, fontSize: 14, fontWeight: "500", lineHeight: 20 }}>
{item.title}
</Text>
{item.description && (
<Text numberOfLines={1} style={{ fontSize: 12, color: mutedColor, lineHeight: 16 }}>
{item.description}
</Text>
)}
</View>
{typeof item.meta === "string" ? (
<Text style={{ color: mutedColor, fontSize: 12, fontWeight: "500", lineHeight: 16 }}>
{item.meta}
</Text>
) : (
item.meta
)}
</>
))}
</Animated.View>
</GestureDetector>
</View>
)
}
export function SwipeableList({
items,
open,
defaultOpen = null,
onOpenChange,
onAction,
actionWidth = 56,
revealThreshold = 34,
closeOnAction = true,
renderItem,
backgroundColor = "#1c1c1c",
textColor = "#f1f1f1",
mutedColor = "#868686",
radius = 16,
testID,
}: SwipeableListProps) {
const [local, setLocal] = useState(defaultOpen)
const change = (next: SwipeableListOpen) => {
if (open === undefined) setLocal(next)
onOpenChange?.(next)
}
return (
<View testID={testID} style={{ gap: 8 }}>
{items.map((item) => (
<Row
key={item.id}
item={item}
open={open === undefined ? local : open}
change={change}
onAction={onAction}
actionWidth={actionWidth}
threshold={revealThreshold}
closeOnAction={closeOnAction}
renderItem={renderItem}
backgroundColor={backgroundColor}
textColor={textColor}
mutedColor={mutedColor}
radius={radius}
/>
))}
</View>
)
}
Give the gestures and motion a run on a real Android phone.