Skip to content

Swipe Actions

A swipe on a list row has two jobs. A short swipe shows a few buttons and waits. A long swipe runs the first one straight away. Everything depends on the line between the two: if people can't see where it is, a peek deletes a message, or a deliberate swipe does nothing.

Inbox
  • Maya Chen9:24
    Friday plansAre we still on for the climbing gym? I can book the 6pm slot.
  • Linear8:51
    3 issues assigned to youENG-412 Sheet snaps past the medium detent on fast flicks
  • Jonah Park8:02
    Re: invoice #2041Paid, thanks! Receipt attached for your records.
  • Studio RentYesterday
    October reminderYour payment of $640 is due on the 15th.
  • Ana RuizYesterday
    Photos from the tripFinally sorted them. The ones from the ridge came out great.
  • GitHubSun
    Your weekly digest12 pull requests merged across 3 repositories.
  • Leo BrandtSat
    Lunch?New ramen place opened on 5th. Thursday works for me.

Swipe a row left a little, then all the way. Swipe right to mark it read, or use the ••• menu.

Let a short swipe reveal the actions and a long swipe commit the first one, and make the moment it switches impossible to miss.

Which side does what

Mail set the convention, and Apple's documentation uses it as its example. The leading edge (swipe right) toggles read. The trailing edge (swipe left) holds Delete and Flag. Actions line up from the edge the swipe starts at, so the first one sits at the screen edge, and a full swipe runs it. Give it the destructive role and it turns red.

| | iPhone | Android | |---|---|---| | Built in | swipeActions on List rows since iOS 15, on any row in iOS 27 with swipeActionsContainer() | SwipeToDismissBox dismisses; a row that reveals buttons needs anchoredDraggable | | Full swipe | Runs the first action, nearest the edge | Material: runs the primary action, the last one at the end | | Undo | Your own, for anything destructive | A Snackbar with one Undo action that stays until used |

Keep one meaning per direction, the same across the app. Nielsen Norman Group found that apps disagree about what each direction does, so people forget which is which. Two destructive actions on opposite edges guarantee mistakes. Deletes that run on a full swipe get an Undo, never an alert (see Destructive Actions).

Announce the commit point

Compose's SwipeToDismissBox dismisses after a fixed 56dp drag, or a fling of 125dp/s, whatever the row's width. On a 400dp row that's 14% of the way across. Try a short drag with each setting.

Inbox
  • Maya ChenFriday plans
  • Linear3 issues assigned to you
  • Jonah ParkRe: invoice #2041
  • Studio RentOctober reminder
  • Ana RuizPhotos from the trip

Drag a row a little way left and let go. The dashed line is where it commits.

With the default, a peek is a delete, and nothing on screen tells you you've crossed the line until the row is gone. Set the threshold to a fraction of the row instead, 40 to 50%, and change three things at the moment it arms: the icon lets go of the edge and jumps to the finger, the colour fills, and a haptic fires. Drag back and it all reverses. Nothing commits until the finger lifts.

I don't let a fast flick commit a destructive swipe. A flick opens or closes the row. Only a release past the line you were shown deletes.

Get the physics right

  • Lock the axis. Claim the drag only after about 10pt of mostly horizontal travel (Android's touch slop is 8dp), and hand the touch back to the list if it goes vertical first. In Gesture Handler that's activeOffsetX plus failOffsetY.
  • Let velocity decide. On release, project the flick forward (see Momentum) and snap open or shut with a spring that starts at the finger's speed.
  • Give at the end. With full swipe off, rubber band past the last button instead of stopping dead.
  • Keep one row open. Opening another row, scrolling, or tapping elsewhere closes the open one. iOS 27's swipeActionsContainer() does exactly this for custom rows.

The back swipe wants the same gesture

Since iOS 26, a pushed screen pops on a left-to-right pan anywhere in its content, not only from the edge (see Back Navigation). So a leading swipe action on a row inside a pushed screen competes with back. In UIKit, make interactiveContentPopGestureRecognizer wait for your recognizer with require(toFail:). I found no SwiftUI modifier for it, so on pushed screens I keep row actions on the trailing edge. On Android, back owns both screen edges, so start row swipes inside the systemGestures insets.

Usage

ContentView.swift
List(messages) { message in
    MessageRow(message)
        .swipeActions(edge: .leading, allowsFullSwipe: false) {
            Button { toggleRead(message) } label: {
                Label(message.isUnread ? "Read" : "Unread", systemImage: "envelope.open")
            }
            .tint(.blue)
        }
        .swipeActions(edge: .trailing) {      // full swipe runs the first: Delete
            Button(role: .destructive) { delete(message) } label: {
                Label("Delete", systemImage: "trash")
            }
            Button { archive(message) } label: {
                Label("Archive", systemImage: "archivebox")
            }
            Button { flag(message) } label: {
                Label("Flag", systemImage: "flag")
            }
            .tint(.orange)
        }
        .contextMenu { MessageActions(message) } // the same actions, no swipe
}

// iOS 27: custom rows outside List, one open at a time, closed on scroll.
// ScrollView { LazyVStack { rows.swipeActions { … } } }.swipeActionsContainer()
val state = rememberSwipeToDismissBoxState(
    positionalThreshold = { width -> width * 0.5f }, // not the fixed 56dp
)
SwipeToDismissBox(
    state = state,
    enableDismissFromStartToEnd = false,             // one direction, one meaning
    onDismiss = {
        onDelete(mail)
        scope.launch {
            val result = snackbar.showSnackbar("Deleted", actionLabel = "Undo")
            if (result == SnackbarResult.ActionPerformed) onUndo(mail)
        }
    },
    backgroundContent = {
        val armed = state.targetValue == SwipeToDismissBoxValue.EndToStart
        Box(
            Modifier.fillMaxSize()
                .background(if (armed) MaterialTheme.colorScheme.error
                            else MaterialTheme.colorScheme.surfaceContainerHighest)
                .padding(horizontal = 24.dp),
            contentAlignment = Alignment.CenterEnd,
        ) { Icon(Icons.Outlined.Delete, contentDescription = null) }
    },
) {
    ListItem(
        headlineContent = { Text(mail.subject) },
        trailingContent = { MailOverflowMenu(mail) }, // the visible twin
    )
}
import ReanimatedSwipeable, {
  type SwipeableMethods,
} from "react-native-gesture-handler/ReanimatedSwipeable";
import * as Haptics from "expo-haptics";

function MailRow({ mail, openRow }: Props) {
  const ref = useRef<SwipeableMethods>(null);
  return (
    <ReanimatedSwipeable
      ref={ref}
      rightThreshold={48}          // open past 48pt or on a flick
      overshootRight={false}       // stop at the buttons, no full swipe
      renderRightActions={(progress, drag) => (
        <MailActions mail={mail} drag={drag} />
      )}
      onSwipeableWillOpen={() => {
        if (openRow.current !== ref.current) openRow.current?.close();
        openRow.current = ref.current; // one row open at a time
        Haptics.selectionAsync();
      }}
    >
      <Row mail={mail} />
    </ReanimatedSwipeable>
  );
}

// A custom pan inside a vertical list: activate on x, fail on y.
// Gesture.Pan().activeOffsetX([-10, 10]).failOffsetY([-10, 10])

Every swipe needs a visible twin: a ••• button or a context menu on the row with the same actions, which is also how keyboard, switch and screen reader users reach them. In Compose, add the actions as customActions semantics. Under reduced motion, keep the row tracking the finger and settle it without bounce.

Resources

Search concepts

Search for a command to run...