Skip to content
pixelfor Expo
Ctrl K

Gesture

Swipeable List

Rows reveal contextual actions with a horizontal spring gesture

Try it on your phone

Swipeable List

Scan to open Swipeable List in Expo Go

Install Expo Go first, then scan this QR to run the native component.

GET EXPO GO ONGoogle Play

This demo uses Expo SDK 54. If your installed Expo Go supports a different SDK, get the compatible build. iOS needs a build supporting this SDK.

Expo code

Original web component

Be UI

Our Expo component

React Native Web

Component 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.

Back to Be UI