# React Swipe to Delete: iOS Swipe Actions With Undo

> Build React swipe to delete on list rows: iOS-style swipe actions, a full swipe that deletes, undo through a toast, and buttons for mouse and keyboard users.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 13, 2026
- Category: [Mobile UI in React](https://wingo-ui.com/blog/category/mobile-ui)
- Reading time: 9 min
- Canonical: https://wingo-ui.com/blog/react-swipe-to-delete

## TL;DR

A React swipe to delete that feels like iOS drags the row on a motion value, snaps open on a short swipe, deletes on a full swipe past about 60% of the row, and keeps only one row open at a time. Delete at once and offer Undo in a toast instead of a confirm dialog, and put the same actions in a menu so mouse, keyboard and screen reader users can delete without dragging. The Wingo UI List builds the swipe, the menu and a long press sheet from one swipe field per row, and the free Toast shows the Undo.

React swipe to delete is the pattern iPhone users reach for without thinking: drag a row to the left, a red Delete appears, keep going and the row is gone. On the web it usually ships as a drag handler that fights vertical scrolling, leaves three rows open at once, deletes without a way back, and does nothing for someone on a laptop. This tutorial builds the iOS behavior step by step with `motion`, then shows the Wingo UI [List](https://wingo-ui.com/components/list), which packages swipe actions, full swipe, a long press sheet and a keyboard menu, with Undo through the free [Toast](https://wingo-ui.com/components/toast).

## What makes swipe to delete feel like iOS?

Five behaviors, and web implementations often skip some of them. Take [react-swipeable-list](https://github.com/marekrozmus/react-swipeable-list), a web package for react swipe actions (1.10.0, published October 11, 2024, MIT, as of October 2026). It has an iOS mode, a `fullSwipe` option that is off by default, a `threshold` of 0.5, and a `destructive` flag that waits 1000ms before calling you; its README does not mention keyboard support. Here is the list we hold our own component to:

1. **The row follows the finger, and only sideways.** A vertical move scrolls the page. A horizontal one slides the row, and the drag locks to that axis once it starts.
2. **A short swipe snaps open.** Release past a quarter of the action width, or flick, and the row parks with its buttons uncovered. Anything less springs back.
3. **A full swipe runs the action.** Apple's SwiftUI [swipeActions](https://developer.apple.com/documentation/swiftui/view/swipeactions(edge:allowsfullswipe:content:)) docs say that by default the user can perform the first action for a given swipe direction with a full swipe. That first action is the outermost button, usually Delete.
4. **One open row at a time.** Opening a second row closes the first. A tap elsewhere or a scroll closes it too.
5. **Both edges.** Trailing actions (Archive, Delete) come out on a swipe to the left, leading ones (Mark read) on a swipe to the right.

Apple's [gestures guidelines](https://developer.apple.com/design/human-interface-guidelines/gestures) add the part that web ports forget: "Give people more than one way to interact with your app." A swipe is a shortcut, never the only path.

## How do you build a swipeable row from scratch?

Put the actions underneath, put the row content on top, and drive the row with a motion value so a `pointermove` never re-renders React. With `drag="x"`, Motion sets `touch-action: pan-y` on the element, so the browser keeps vertical scrolling and hands you the horizontal moves:

```tsx
"use client";

import { useRef, type ReactNode } from "react";
import { animate, motion, useMotionValue, useTransform, type PanInfo } from "motion/react";

// width of the revealed Delete button, in px
const ACTION = 76;
// a swipe past 60% of the row deletes without a tap
const FULL = 0.6;

export function SwipeRow({ children, onDelete }: { children: ReactNode; onDelete: () => void }) {
  const rowRef = useRef<HTMLDivElement>(null);
  const x = useMotionValue(0);
  // the button fades in as the row uncovers it, with no re-render
  const opacity = useTransform(x, [-ACTION, 0], [1, 0]);

  const remove = () => {
    const width = rowRef.current?.offsetWidth ?? 0;
    void animate(x, -width, { duration: 0.2 }).then(onDelete);
  };

  const onDragEnd = (_: unknown, info: PanInfo) => {
    const width = rowRef.current?.offsetWidth ?? 0;
    const offset = -x.get();
    if (offset > width * FULL) return remove();
    // a flick to the left, or a quarter of the button, opens the row; anything else closes it
    const open = info.velocity.x < -400 || (info.velocity.x < 400 && offset > ACTION * 0.25);
    void animate(x, open ? -ACTION : 0, { type: "spring", stiffness: 500, damping: 40 });
  };

  return (
    <div ref={rowRef} className="relative overflow-hidden">
      <motion.button
        type="button"
        onClick={remove}
        style={{ opacity }}
        className="absolute inset-y-0 end-0 w-[76px] bg-destructive text-sm font-medium text-destructive-foreground"
      >
        Delete
      </motion.button>
      <motion.div
        drag="x"
        dragDirectionLock
        dragMomentum={false}
        dragConstraints={{ right: 0 }}
        dragElastic={{ right: 0 }}
        onDragEnd={onDragEnd}
        style={{ x }}
        className="relative bg-background"
      >
        {children}
      </motion.div>
    </div>
  );
}
```

That is a working slide to delete row, and only a skeleton. What it still misses: the hidden button stays in the Tab order while the row is closed (put `inert` on the action layer until it opens), a swipe that ends on a link fires its click, a second open row does not close the first, there is no haptic tick when the full swipe arms, and there is no way to delete without dragging. The 400px/s flick speed and the quarter-width threshold match the shared gesture constants in Wingo UI's `lib/motion`, which are tuned to dismiss at the same point as a vaul drawer.

## How do you add swipe actions to a React list with Wingo UI?

Give each row a `swipe` field with `leading` and `trailing` arrays. Each action is `{ id, label, icon?, tone?, color?, onSelect, destructive? }`. A `destructive` action slides the row out and collapses the gap, and you remove the item in `onSelect`. If the item is still in the list 700ms after `onSelect` settles (a cancelled confirm, a failed request), the row slides back on its own.

```tsx
"use client";

import { Archive, Mail, MailOpen, Trash2 } from "lucide-react";
import { List, type ListItemData } from "@/components/ui/list";

type Message = { id: string; from: string; subject: string; unread: boolean };

type InboxProps = {
  messages: Message[];
  onOpen: (id: string) => void;
  onToggleRead: (id: string) => void;
  onRemove: (message: Message, folder: "archive" | "trash") => void;
};

export function InboxList({ messages, onOpen, onToggleRead, onRemove }: InboxProps) {
  const items: ListItemData[] = messages.map((message) => ({
    id: message.id,
    title: message.from,
    description: message.subject,
    avatar: { name: message.from },
    onSelect: () => onOpen(message.id),
    swipe: {
      leading: [
        {
          id: "read",
          label: message.unread ? "Read" : "Unread",
          icon: message.unread ? <MailOpen /> : <Mail />,
          tone: "info",
          onSelect: () => onToggleRead(message.id),
        },
      ],
      trailing: [
        { id: "archive", label: "Archive", icon: <Archive />, destructive: true, onSelect: () => onRemove(message, "archive") },
        { id: "delete", label: "Delete", icon: <Trash2 />, tone: "danger", destructive: true, onSelect: () => onRemove(message, "trash") },
      ],
    },
  }));

  return <List aria-label="Inbox" variant="plain" items={items} />;
}
```

The swipe follows the five behaviors above. Buttons are 76px wide and their icons scale up from 60% as they come out. Release past a quarter of the buttons' width or flick at 400px/s to snap open. Past 60% of the row the outermost action arms, covers the others and fires one short `navigator.vibrate(10)` tick where the browser supports it; release there and it runs. Turn that off with `fullSwipe={false}`. Only one row stays open, and tapping it, tapping elsewhere or scrolling closes it. A mouse never starts a swipe by default, so text selection and clicks behave as usual on a laptop. `swipeWithMouse` lets a mouse drag swipe too, which is how you can try it below.

> Live demo (List): Drag a row to the left with the mouse (swipeWithMouse is on in this demo) to uncover Archive and Delete, or keep dragging past 60% to delete it in one move. Drag right to mark it read. Try it at [List](https://wingo-ui.com/components/list) and install it with `npx wingo-ui@latest add list`.

Inputs, textareas, selects and anything inside `[data-no-swipe]` never start a swipe, so a Switch in a row's `trailing` slot stays usable. If your list sits on a surface other than the page background, set the swipe surface with `className="[--list-bg:var(--card)]"` so the sliding row stays opaque.

## Should swipe to delete ask for confirmation or offer undo?

Offer undo. Swipe to delete with undo beats a confirm for most lists: a full swipe is already deliberate, and a dialog after every delete trains people to click Confirm without reading. The Nielsen Norman Group article [Confirmation Dialogs Can Prevent User Errors, If Not Overused](https://www.nngroup.com/articles/confirmation-dialog/) puts it plainly: "if you warn people too much, they stop paying attention." The same article tells designers to go to great lengths to provide undo.

The pattern is three steps: remove the row, tell the server, and show a toast whose Undo puts it back. If the request fails, put the row back and say so, or the list shows a delete the server never made.

```tsx
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "@/components/ui/toast";
import { InboxList } from "./inbox-list";

type Message = { id: string; from: string; subject: string; unread: boolean };
type Folder = "inbox" | "archive" | "trash";

export function Inbox({ initial, move }: { initial: Message[]; move: (id: string, folder: Folder) => Promise<void> }) {
  const router = useRouter();
  const [messages, setMessages] = useState(initial);

  const remove = (message: Message, folder: "archive" | "trash") => {
    const index = messages.findIndex((m) => m.id === message.id);
    const restore = () =>
      setMessages((prev) =>
        prev.some((m) => m.id === message.id) ? prev : [...prev.slice(0, index), message, ...prev.slice(index)],
      );
    setMessages((prev) => prev.filter((m) => m.id !== message.id));
    move(message.id, folder).catch(() => {
      restore();
      toast.error("Could not move the message", { description: message.subject });
    });
    toast(folder === "trash" ? "Message deleted" : "Message archived", {
      description: message.subject,
      duration: 6000,
      action: {
        label: "Undo",
        onClick: () => {
          restore();
          void move(message.id, "inbox");
        },
      },
    });
  };

  return (
    <InboxList
      messages={messages}
      onOpen={(id) => router.push(`/inbox/${id}`)}
      onToggleRead={(id) => setMessages((prev) => prev.map((m) => (m.id === id ? { ...m, unread: !m.unread } : m)))}
      onRemove={remove}
    />
  );
}
```

Mount `<Toaster />` once in your root layout and `toast()` works from anywhere. The Toast timer pauses while the pointer is over the stack, while keyboard focus is inside it and while the tab is in the background, so the Undo does not expire while someone is reading it. Alt+T jumps to the newest toast for keyboard users. If your list is sorted by date, sort again on undo instead of reusing the index, since other deletes may have shifted it.

> Live demo (Toast): Press Delete, then press Undo in the toast before it times out: the listing comes back where it was. Try it at [Toast](https://wingo-ui.com/components/toast) and install it with `npx wingo-ui@latest add toast`.

Where you commit the delete matters more than the animation. Moving the message to a trash folder right away and moving it back on Undo (a soft delete) survives a closed tab. Waiting for the toast's `onAutoClose` and `onDismiss` callbacks before you call the server does not: if the user closes the tab during those six seconds, nothing reaches the server. Keep a real confirm step for deletes you cannot restore, such as wiping an account. Our [slide to confirm guide](https://wingo-ui.com/blog/react-slide-to-confirm) covers that case.

## How do mouse and keyboard users delete the row?

Through the same actions, in a menu. A swipe is a dragging movement, and [WCAG 2.2 success criterion 2.5.7](https://www.w3.org/WAI/WCAG22/Understanding/dragging-movements.html) (level AA) asks for a single-pointer alternative to anything that needs a drag. The List builds that alternative from the `swipe` field, so you never maintain two lists of actions:

- **Mouse:** a ⋯ button shows on hover and focus, and right click opens the same menu. `actionsVisibility="always"` keeps the button visible.
- **Keyboard:** Tab to the row, then Shift+F10 or the Menu key opens the menu, and focus returns to the row when it closes. Closed swipe buttons are `inert`, so Tab never lands on a hidden Delete.
- **Touch without swiping:** a long press opens a bottom sheet with the same actions, so someone who cannot drag still reaches Delete with a tap.

The menu lists the row's own `actions` first, then every swipe action, with Delete in the danger tone. On phones the ⋯ button is always visible, with a 44px hit area, the size our [minimum touch target guide](https://wingo-ui.com/blog/minimum-touch-target-size-react-tailwind) recommends.

Outside a list, a delete still needs a visible button. On a message detail page or a toolbar, the free [Delete Button](https://wingo-ui.com/components/delete-button) confirms inline: the bin opens a small check and cross panel in place, Escape backs out, and the result is announced to screen readers. Name what it deletes when several sit on one screen.

```tsx
import { DeleteButton } from "@/components/ui/delete-button";

export function MessageToolbar({ subject, onDelete }: { subject: string; onDelete: () => void }) {
  return <DeleteButton size="sm" label={`Delete ${subject}`} onConfirm={onDelete} />;
}
```

> Live demo (Delete Button): Click the bin, then the check to delete or the cross to keep it. Escape backs out too. Try it at [Delete Button](https://wingo-ui.com/components/delete-button) and install it with `npx wingo-ui@latest add delete-button`.

## Which swipe to delete pitfalls show up on real phones?

Check these before you ship an iOS swipe to delete on the web, with our component or your own:

- **The back gesture.** Safari on iOS goes back when a swipe starts at the left edge of the screen. Leading actions on a full-width row compete with it, so keep the most important actions trailing, and remember Apple's advice to avoid conflicting with gestures that access system UI.
- **A click after the swipe.** A row that is also a link opens when the drag ends over it. Swallow the click when the pointer moved more than a few pixels (the List uses 8px), and treat a tap on an open row as close.
- **Rows that unmount mid-animation.** If you remove the item before the slide-out finishes, the row vanishes instead of leaving. Let the animation run first, as `animate(...).then(onDelete)` does above, or use a component that handles it.
- **Haptics as the only signal.** The tick when the full swipe arms is a bonus. As of October 2026, [caniuse](https://caniuse.com/vibration) shows no `navigator.vibrate` in Safari, so the armed state must also show on screen. Our [Web Vibration API guide](https://wingo-ui.com/blog/web-vibration-api-haptic-feedback) covers the gaps.
- **Undo that targets the wrong row.** Keep the removed object in the toast's closure, not its index alone, and keep the toast long enough to read (6 seconds above).

For the rest of the mobile layer, from bottom sheets to safe areas, read our guide to [mobile-first React components](https://wingo-ui.com/blog/mobile-first-react-components) or browse [Mobile UI in React](https://wingo-ui.com/blog/category/mobile-ui). Swipe cards are the next step up from swipe rows: the [React swipe cards tutorial](https://wingo-ui.com/blog/react-swipe-cards) builds a Tinder-style deck with the same motion values.

## What should you install next?

Start with the list your app already has. `npx wingo-ui@latest add list` copies the List with its swipe actions, menu, long press sheet and drag to reorder, plus the components and hooks it imports, and installs its npm dependencies: `motion`, `lucide-react`, `@radix-ui/react-slot` and the `@dnd-kit` packages. The List is part of [Wingo UI Pro](https://wingo-ui.com/pricing), so sign in first with `npx wingo-ui@latest login`. The Toast for the Undo is free: `npx wingo-ui@latest add toast`.

## Components in this post

- [List](https://wingo-ui.com/components/list) (Pro): A React list for settings, contacts, inboxes and orders: iOS grouped panels, selection, row menus, swipe actions and drag to reorder. Install: `npx wingo-ui@latest add list`
- [Toast](https://wingo-ui.com/components/toast) (Free): React toast notifications: mount one Toaster and call toast() from anywhere, with stacking, swipe to dismiss and a pause while you read. Install: `npx wingo-ui@latest add toast`
- [Delete Button](https://wingo-ui.com/components/delete-button) (Free): A React delete button with inline confirmation: the bin opens a small confirm and cancel panel in place, no dialog needed. Install: `npx wingo-ui@latest add delete-button`

## FAQ

### How do I make swipe to delete work on the web like on iOS?

Drag the row horizontally on a motion value with touch-action: pan-y so vertical scrolling still works, uncover action buttons underneath, snap open past a quarter of the button width or on a flick, and run the outermost action when the swipe passes about 60% of the row. Close any other open row when a new one opens.

### Should swipe to delete ask for confirmation?

Usually not. A confirm dialog after a deliberate full swipe adds a step to every delete, and people learn to click through it; removing the row and offering Undo in a toast for a few seconds protects against mistakes without slowing anyone down. Keep a confirm step for deletes you cannot restore.

### Is swipe to delete accessible?

Only with an alternative. WCAG 2.2 success criterion 2.5.7 Dragging Movements (level AA) asks for a single-pointer way to do anything that needs a drag, so put the same actions in a row menu or a visible button that keyboards and screen readers can reach.

### How do I stop the swipe from fighting vertical scrolling?

Set touch-action: pan-y on the row so the browser keeps vertical panning and hands horizontal moves to your handler, and lock the drag to one axis once it starts. Motion does both with drag="x" and dragDirectionLock.

### Is the Wingo UI List free?

No. The List with swipe actions is a Pro component. The Toast that shows the Undo and the Delete Button are free, and npx wingo-ui@latest add list copies the List and everything it imports once you sign in with a Pro account.

---

Source: https://wingo-ui.com/blog/react-swipe-to-delete
