Skip to content
WingoUI
ComponentsTemplatesDocsPricingAI agentsBlogChangelog
  1. Home
  2. Blog
  3. Mobile UI in React
  4. React Swipe to Delete: iOS Swipe Actions With Undo
Auf Deutsch lesen
  1. Blog
  2. Mobile UI in React
  3. React Swipe to Delete: iOS Swipe Actions With Undo
Mobile UI in React
Mobile UI in React

React Swipe to Delete: iOS Swipe Actions With Undo

SR
Serban Rusu · Founder of Wingo UI
Oct 13, 2026 · 9 min read

On this page

0%
  1. What makes swipe to delete feel like iOS?
  2. How do you build a swipeable row from scratch?
  3. How do you add swipe actions to a React list with Wingo UI?
  4. Should swipe to delete ask for confirmation or offer undo?
  5. How do mouse and keyboard users delete the row?
  6. Which swipe to delete pitfalls show up on real phones?
  7. What should you install next?
  8. FAQ
    1. How do I make swipe to delete work on the web like on iOS?
    2. Should swipe to delete ask for confirmation?
    3. Is swipe to delete accessible?
    4. How do I stop the swipe from fighting vertical scrolling?
    5. Is the Wingo UI List free?

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.

Published Oct 13, 2026

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, which packages swipe actions, full swipe, a long press sheet and a keyboard menu, with Undo through the free Toast.

What makes swipe to delete feel like iOS?

Five behaviors, and web implementations often skip some of them. Take react-swipeable-list (opens in a new tab), 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 (opens in a new tab) 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 (opens in a new tab) 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.

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.
$ npx wingo-ui@latest add list
ProList docs

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 (opens in a new tab) 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.

Press Delete, then press Undo in the toast before it times out: the listing comes back where it was.
$ npx wingo-ui@latest add toast
FreeToast docs

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 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 (opens in a new tab) (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 recommends.

Outside a list, a delete still needs a visible button. On a message detail page or a toolbar, the free 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} />;
}
Click the bin, then the check to delete or the cross to keep it. Escape backs out too.
$ npx wingo-ui@latest add delete-button
FreeDelete Button docs

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 (opens in a new tab) shows no navigator.vibrate in Safari, so the armed state must also show on screen. Our Web Vibration API guide 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 or browse Mobile UI in React. Swipe cards are the next step up from swipe rows: the React swipe cards tutorial 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, 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

    A React list for settings, contacts, inboxes and orders: iOS grouped panels, selection, row menus, swipe actions and drag to reorder.

    Pro
  • Toast

    React toast notifications: mount one Toaster and call toast() from anywhere, with stacking, swipe to dismiss and a pause while you read.

    Free
  • Delete Button

    A React delete button with inline confirmation: the bin opens a small confirm and cancel panel in place, no dialog needed.

    Free

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.

  • Mobile UI
  • Swipe actions
  • Gestures
  • React
  • Accessibility

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu founded Wingo UI. He builds its component library, CLI and MCP server, and writes about React interfaces for phones and AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Mobile UI in React

React Slide to Confirm: When It Beats a Confirm Dialog

When a React slide to confirm beats a confirm dialog for irreversible actions, and how to keep it accessible with a keyboard path and status announcements.

SRSerban Rusu·Oct 13, 2026·8 min read
Mobile UI in React

React Pull to Refresh in a PWA Without Fighting the Browser

Build a React pull to refresh for a PWA: overscroll-behavior, rubberband resistance, iOS spokes or a Material ring, a haptic tick and a refresh button.

SRSerban Rusu·Oct 9, 2026·11 min read
Mobile UI in React

Responsive Table Mobile Layout: Rows to Cards in React

A responsive table mobile guide: when rows should become cards below 768px, which columns survive, and how sorting and bulk actions work without headers.

SRSerban Rusu·Oct 9, 2026·11 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components and blocks. Copy the source, make it yours, and let your coding agent build with it.

Product

  • Components
  • Templates
  • Pricing
  • AI agents
  • Theme generator

Resources

  • Docs
  • Blog
  • Changelog
  • Changelog RSS
  • llms.txt

Legal

  • Terms
  • Privacy
  • Refunds
  • License
  • Imprint

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities

© 2026 Wingo UI

Wingo UI