# React Swipe to Delete: iOS-Swipe-Actions mit Undo

> React Swipe to Delete für Listenzeilen: iOS-Swipe-Actions, ein Full Swipe zum Löschen, Undo per Toast und Buttons für alle mit Maus oder Tastatur.

- Autor: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Veröffentlicht: 13. Okt. 2026
- Kategorie: [Mobile UI mit React](https://wingo-ui.com/de/blog/category/mobile-ui)
- Lesezeit: 10 Min.
- Kanonische URL: https://wingo-ui.com/de/blog/react-swipe-to-delete
- Englische Fassung: https://wingo-ui.com/blog/react-swipe-to-delete

## Kurz gesagt

Ein React Swipe to Delete, das sich wie iOS anfühlt, zieht die Zeile über einen Motion Value, rastet nach einem kurzen Swipe offen ein, löscht bei einem Full Swipe über etwa 60 % der Zeile und lässt immer nur eine Zeile offen. Lösch sofort und biete statt eines Bestätigungsdialogs Undo in einem Toast an, und leg dieselben Aktionen in ein Menü, damit Maus-, Tastatur- und Screenreader-Nutzer ohne Ziehen löschen können. Die List von Wingo UI baut Swipe, Menü und ein Long-Press-Sheet aus einem einzigen swipe-Feld pro Zeile, und der kostenlose Toast zeigt das Undo.

React Swipe to Delete ist das Muster, zu dem iPhone-Nutzer ganz ohne Nachdenken greifen: Zeile nach links ziehen, ein rotes „Löschen“ erscheint, weiterziehen, und die Zeile ist weg. Im Web kommt es meist als Drag-Handler daher, der mit dem vertikalen Scrollen kämpft, drei Zeilen gleichzeitig offen lässt, ohne Weg zurück löscht und auf dem Laptop gar nichts tut. Dieses Tutorial baut das iOS-Verhalten Schritt für Schritt mit `motion` nach und zeigt dann die [List](https://wingo-ui.com/components/list) von Wingo UI. Sie bündelt Swipe Actions, Full Swipe, ein Long-Press-Sheet und ein Tastaturmenü, mit Undo über den kostenlosen [Toast](https://wingo-ui.com/components/toast).

## Wann fühlt sich Swipe to Delete wie unter iOS an?

Wenn fünf Verhaltensweisen stimmen. Web-Umsetzungen lassen oft einige davon weg. Ein Beispiel ist [react-swipeable-list](https://github.com/marekrozmus/react-swipeable-list), ein Web-Paket für React Swipe Actions (1.10.0, veröffentlicht am 11. Oktober 2024, MIT, Stand Oktober 2026). Es hat einen iOS-Modus, eine Option `fullSwipe`, die standardmäßig aus ist, einen `threshold` von 0.5 und ein Flag `destructive`, das 1000ms wartet, bevor es deinen Callback aufruft; das README erwähnt keine Tastaturunterstützung. An dieser Liste messen wir unsere eigene Komponente:

1. **Die Zeile folgt dem Finger, und zwar nur seitwärts.** Eine vertikale Bewegung scrollt die Seite. Eine horizontale schiebt die Zeile, und der Drag wird auf diese Achse festgelegt, sobald er beginnt.
2. **Ein kurzer Swipe rastet offen ein.** Lässt du nach mehr als einem Viertel der Action-Breite los oder machst einen Flick, bleibt die Zeile mit freigelegten Buttons stehen. Alles darunter federt zurück.
3. **Ein Full Swipe führt die Aktion aus.** Laut Apples SwiftUI-Doku zu [swipeActions](https://developer.apple.com/documentation/swiftui/view/swipeactions(edge:allowsfullswipe:content:)) kann man standardmäßig die erste Aktion einer Swipe-Richtung per Full Swipe auslösen. Diese erste Aktion ist der äußerste Button, meist Löschen.
4. **Immer nur eine offene Zeile.** Öffnest du eine zweite Zeile, schließt sich die erste. Ein Tippen an anderer Stelle oder Scrollen schließt sie ebenfalls.
5. **Beide Seiten.** Trailing Actions (Archivieren, Löschen) kommen bei einem Swipe nach links heraus, Leading Actions (Als gelesen markieren) bei einem Swipe nach rechts.

Apples [Richtlinien zu Gesten](https://developer.apple.com/design/human-interface-guidelines/gestures) ergänzen den Teil, den Web-Portierungen vergessen: Menschen sollen mehr als einen Weg haben, eine App zu bedienen. Ein Swipe ist eine Abkürzung, nie der einzige Weg.

## Wie baust du eine Swipe-Zeile von Grund auf selbst?

Leg die Aktionen in eine untere Ebene, den Inhalt der Zeile darüber, und treib die Zeile mit einem Motion Value an, damit kein `pointermove` React neu rendert. Mit `drag="x"` setzt Motion `touch-action: pan-y` auf das Element. Der Browser behält also das vertikale Scrollen und gibt dir die horizontalen Bewegungen:

```tsx
"use client";

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

// Breite des freigelegten Löschen-Buttons, in px
const ACTION = 76;
// ein Swipe über 60 % der Zeile löscht ohne Tippen
const FULL = 0.6;

export function SwipeRow({ children, onDelete }: { children: ReactNode; onDelete: () => void }) {
  const rowRef = useRef<HTMLDivElement>(null);
  const x = useMotionValue(0);
  // der Button blendet ein, während die Zeile ihn freilegt, ohne 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();
    // ein Flick nach links oder ein Viertel des Buttons öffnet die Zeile; alles andere schließt sie
    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"
      >
        Löschen
      </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>
  );
}
```

Das ist eine funktionierende Zeile für Slide to Delete, aber nur ein Gerüst. Was noch fehlt: Der versteckte Button bleibt in der Tab-Reihenfolge, solange die Zeile zu ist (setz `inert` auf die Action-Ebene, bis sie aufgeht). Ein Swipe, der auf einem Link endet, löst dessen Klick aus. Eine zweite offene Zeile schließt die erste nicht. Es gibt kein haptisches Ticken, wenn der Full Swipe scharf wird, und keinen Weg, ohne Ziehen zu löschen. Die Flick-Geschwindigkeit von 400px/s und die Schwelle von einem Viertel der Breite entsprechen den gemeinsamen Gesten-Konstanten in `lib/motion` von Wingo UI. Sie sind so abgestimmt, dass sie an derselben Stelle schließen wie ein vaul-Drawer.

## Wie fügst du einer React-Liste mit Wingo UI Swipe Actions hinzu?

Gib jeder Zeile ein Feld `swipe` mit den Arrays `leading` und `trailing`. Jede Aktion ist `{ id, label, icon?, tone?, color?, onSelect, destructive? }`. Eine `destructive`-Aktion schiebt die Zeile hinaus und schließt die Lücke, und du entfernst das Element in `onSelect`. Ist das Element 700ms nach dem Abschluss von `onSelect` noch in der Liste (eine abgebrochene Bestätigung, ein fehlgeschlagener Request), gleitet die Zeile von selbst zurück.

```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 ? "Gelesen" : "Ungelesen",
          icon: message.unread ? <MailOpen /> : <Mail />,
          tone: "info",
          onSelect: () => onToggleRead(message.id),
        },
      ],
      trailing: [
        { id: "archive", label: "Archivieren", icon: <Archive />, destructive: true, onSelect: () => onRemove(message, "archive") },
        { id: "delete", label: "Löschen", icon: <Trash2 />, tone: "danger", destructive: true, onSelect: () => onRemove(message, "trash") },
      ],
    },
  }));

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

Der Swipe folgt den fünf Verhaltensweisen von oben. Die Buttons sind 76px breit, und ihre Icons wachsen beim Herauskommen von 60 % auf volle Größe. Lass nach mehr als einem Viertel der Button-Breite los oder mach einen Flick mit 400px/s, dann rastet die Zeile offen ein. Ab 60 % der Zeile wird die äußerste Aktion scharf, überdeckt die anderen und löst ein kurzes `navigator.vibrate(10)` aus, wo der Browser das unterstützt; lässt du dort los, läuft sie. Abschalten kannst du das mit `fullSwipe={false}`. Nur eine Zeile bleibt offen, und ein Tippen auf sie, ein Tippen an anderer Stelle oder Scrollen schließt sie. Eine Maus startet standardmäßig nie einen Swipe, damit Textauswahl und Klicks auf dem Laptop normal funktionieren. Mit `swipeWithMouse` löst auch Ziehen mit der Maus einen Swipe aus, und so kannst du es unten ausprobieren.

> Live-Demo (List): Zieh eine Zeile mit der Maus nach links (swipeWithMouse ist in dieser Demo an), um Archivieren und Löschen freizulegen, oder zieh weiter über 60 %, um sie in einer Bewegung zu löschen. Zieh nach rechts, um sie als gelesen zu markieren. Probier sie unter [List](https://wingo-ui.com/components/list) aus und installier sie mit `npx wingo-ui@latest add list`.

Inputs, Textareas, Selects und alles in `[data-no-swipe]` starten nie einen Swipe, ein Switch im `trailing`-Slot einer Zeile bleibt also bedienbar. Liegt deine Liste auf einer anderen Fläche als dem Seitenhintergrund, setz die Swipe-Fläche mit `className="[--list-bg:var(--card)]"`, damit die gleitende Zeile deckend bleibt.

## Soll Swipe to Delete nachfragen oder Undo anbieten?

Biete Undo an. Swipe to Delete mit Undo schlägt eine Bestätigung bei den meisten Listen: Ein Full Swipe ist schon eine bewusste Handlung, und ein Dialog nach jedem Löschen bringt Leuten bei, ohne zu lesen auf Bestätigen zu klicken. Der Artikel der Nielsen Norman Group [Confirmation Dialogs Can Prevent User Errors, If Not Overused](https://www.nngroup.com/articles/confirmation-dialog/) sagt es deutlich: Wer zu oft warnt, dem hört irgendwann niemand mehr zu. Derselbe Artikel rät Designern, keinen Aufwand zu scheuen, um Undo anzubieten.

Das Muster hat drei Schritte: Zeile entfernen, dem Server Bescheid geben und einen Toast zeigen, dessen Undo sie zurückholt. Schlägt der Request fehl, hol die Zeile zurück und zeig eine Meldung, sonst zeigt die Liste ein Löschen, das der Server nie ausgeführt hat.

```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("Nachricht konnte nicht verschoben werden", { description: message.subject });
    });
    toast(folder === "trash" ? "Nachricht gelöscht" : "Nachricht archiviert", {
      description: message.subject,
      duration: 6000,
      action: {
        label: "Rückgängig",
        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}
    />
  );
}
```

Häng `<Toaster />` einmal in dein Root-Layout, dann funktioniert `toast()` überall. Der Timer des Toasts pausiert, solange der Zeiger über dem Stapel liegt, solange der Tastaturfokus darin steht und solange der Tab im Hintergrund ist. So läuft das Undo nicht ab, während jemand es liest. Mit Alt+T springen Tastaturnutzer zum neuesten Toast. Ist deine Liste nach Datum sortiert, sortier beim Undo neu, statt den Index wiederzuverwenden, denn andere Löschvorgänge können ihn verschoben haben.

> Live-Demo (Toast): Drück Löschen und dann Undo im Toast, bevor er abläuft: Der Eintrag kommt an seine alte Stelle zurück. Probier sie unter [Toast](https://wingo-ui.com/components/toast) aus und installier sie mit `npx wingo-ui@latest add toast`.

Wo du das Löschen festschreibst, zählt mehr als die Animation. Die Nachricht sofort in einen Papierkorb-Ordner zu verschieben und sie bei Undo zurückzuholen (ein Soft Delete), übersteht einen geschlossenen Tab. Mit dem Server-Aufruf auf die Callbacks `onAutoClose` und `onDismiss` des Toasts zu warten, übersteht ihn nicht: Schließt jemand den Tab in diesen sechs Sekunden, kommt beim Server nichts an. Behalte eine echte Bestätigung für Löschvorgänge, die du nicht rückgängig machen kannst, etwa das Löschen eines Kontos. Unser [Guide zu Slide to Confirm](https://wingo-ui.com/de/blog/react-slide-to-confirm) behandelt diesen Fall.

## Wie löschen Maus- und Tastaturnutzer die Zeile?

Über dieselben Aktionen, in einem Menü. Ein Swipe ist eine Ziehbewegung, und das [WCAG-2.2-Erfolgskriterium 2.5.7](https://www.w3.org/WAI/WCAG22/Understanding/dragging-movements.html) (Stufe AA) verlangt für alles, was ein Ziehen braucht, eine Alternative mit einem einzelnen Zeiger. Die List baut diese Alternative aus dem Feld `swipe`, du pflegst also nie zwei Listen mit Aktionen:

- **Maus:** Ein ⋯-Button erscheint bei Hover und Fokus, und ein Rechtsklick öffnet dasselbe Menü. Mit `actionsVisibility="always"` bleibt der Button sichtbar.
- **Tastatur:** Mit Tab auf die Zeile, dann öffnet Shift+F10 oder die Menütaste das Menü, und beim Schließen kehrt der Fokus zur Zeile zurück. Geschlossene Swipe-Buttons sind `inert`, Tab landet also nie auf einem versteckten Löschen.
- **Touch ohne Swipe:** Ein Long Press öffnet ein Bottom Sheet mit denselben Aktionen. Wer nicht ziehen kann, erreicht Löschen trotzdem mit einem Tipp.

Das Menü listet zuerst die eigenen `actions` der Zeile, dann jede Swipe-Aktion, mit Löschen im Danger-Ton. Auf Smartphones ist der ⋯-Button immer sichtbar, mit einer Trefferfläche von 44px, der Größe, die unser [Guide zur minimalen Touch-Target-Größe](https://wingo-ui.com/de/blog/minimum-touch-target-size-react-tailwind) empfiehlt.

Außerhalb einer Liste braucht ein Löschen trotzdem einen sichtbaren Button. Auf einer Detailseite einer Nachricht oder in einer Toolbar bestätigt der kostenlose [Delete Button](https://wingo-ui.com/components/delete-button) inline: Der Mülleimer öffnet an Ort und Stelle ein kleines Panel mit Haken und Kreuz, Escape bricht ab, und das Ergebnis wird Screenreadern angesagt. Benenne, was er löscht, wenn mehrere auf einem Screen stehen.

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

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

> Live-Demo (Delete Button): Klick auf den Mülleimer, dann auf den Haken zum Löschen oder auf das Kreuz zum Behalten. Escape bricht ebenfalls ab. Probier sie unter [Delete Button](https://wingo-ui.com/components/delete-button) aus und installier sie mit `npx wingo-ui@latest add delete-button`.

## Welche Stolperfallen bei Swipe to Delete zeigen sich auf echten Smartphones?

Prüf diese Punkte, bevor du ein iOS Swipe to Delete im Web auslieferst, ob mit unserer Komponente oder deiner eigenen:

- **Die Zurück-Geste.** Safari auf iOS navigiert zurück, wenn ein Swipe am linken Bildschirmrand beginnt. Leading Actions auf einer Zeile in voller Breite konkurrieren damit, also leg die wichtigsten Aktionen auf die Trailing-Seite und denk an Apples Rat, nicht mit Gesten zu kollidieren, die System-UI aufrufen.
- **Ein Klick nach dem Swipe.** Eine Zeile, die auch ein Link ist, öffnet sich, wenn der Drag über ihr endet. Schluck den Klick, wenn sich der Zeiger mehr als ein paar Pixel bewegt hat (die List nimmt 8px), und behandle einen Tipp auf eine offene Zeile als Schließen.
- **Zeilen, die mitten in der Animation unmounten.** Entfernst du das Element, bevor das Hinausgleiten fertig ist, verschwindet die Zeile, statt hinauszugleiten. Lass die Animation zuerst laufen, wie es `animate(...).then(onDelete)` oben tut, oder nimm eine Komponente, die das erledigt.
- **Haptik als einziges Signal.** Das Ticken, wenn der Full Swipe scharf wird, ist ein Bonus. Laut [caniuse](https://caniuse.com/vibration) gibt es Stand Oktober 2026 kein `navigator.vibrate` in Safari, also muss der scharfe Zustand auch auf dem Bildschirm sichtbar sein. Unser [Guide zur Web Vibration API](https://wingo-ui.com/de/blog/web-vibration-api-haptic-feedback) behandelt die Lücken.
- **Undo, das die falsche Zeile trifft.** Behalte das entfernte Objekt in der Closure des Toasts, nicht nur seinen Index, und lass den Toast lange genug stehen, um ihn zu lesen (oben 6 Sekunden).

Für alles Weitere rund um mobile UI, von Bottom Sheets bis zu Safe Areas, lies unseren Guide zu [Mobile-first React-Komponenten](https://wingo-ui.com/de/blog/mobile-first-react-components) oder stöber in [Mobile UI in React](https://wingo-ui.com/de/blog/category/mobile-ui). Swipe Cards sind die nächste Stufe nach Swipe-Zeilen: Das [Tutorial zu React Swipe Cards](https://wingo-ui.com/de/blog/react-swipe-cards) baut einen Stapel im Tinder-Stil mit denselben Motion Values.

## Was solltest du als Nächstes installieren?

Fang mit der Liste an, die deine App schon hat. `npx wingo-ui@latest add list` kopiert die List mit Swipe Actions, Menü, Long-Press-Sheet und Drag zum Umsortieren, dazu die Komponenten und Hooks, die sie importiert, und installiert ihre npm-Abhängigkeiten: `motion`, `lucide-react`, `@radix-ui/react-slot` und die `@dnd-kit`-Pakete. Die List gehört zu [Wingo UI Pro](https://wingo-ui.com/de/pricing), melde dich also zuerst mit `npx wingo-ui@latest login` an. Der Toast für das Undo ist kostenlos: `npx wingo-ui@latest add toast`.

## Komponenten in diesem Artikel

- [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. Installation: `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. Installation: `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. Installation: `npx wingo-ui@latest add delete-button`

## Häufige Fragen

### Wie funktioniert Swipe to Delete im Web wie unter iOS?

Zieh die Zeile horizontal über einen Motion Value mit touch-action: pan-y, damit vertikales Scrollen weiter funktioniert, leg Action-Buttons darunter frei, lass sie ab einem Viertel der Button-Breite oder bei einem Flick offen einrasten und führ die äußerste Aktion aus, wenn der Swipe etwa 60 % der Zeile überschreitet. Schließ jede andere offene Zeile, sobald eine neue aufgeht.

### Soll Swipe to Delete eine Bestätigung abfragen?

Meistens nicht. Ein Bestätigungsdialog nach einem bewussten Full Swipe fügt jedem Löschen einen Schritt hinzu, und Leute gewöhnen sich an, ihn ungelesen wegzuklicken; die Zeile zu entfernen und ein paar Sekunden lang Undo in einem Toast anzubieten, schützt vor Fehlern, ohne jemanden auszubremsen. Behalte eine Bestätigung für Löschvorgänge, die du nicht rückgängig machen kannst.

### Ist Swipe to Delete barrierefrei?

Nur mit einer Alternative. Das WCAG-2.2-Erfolgskriterium 2.5.7 Dragging Movements (Stufe AA) verlangt für alles, was ein Ziehen erfordert, einen Weg mit einem einzelnen Zeiger. Leg dieselben Aktionen also in ein Zeilenmenü oder einen sichtbaren Button, den Tastatur und Screenreader erreichen.

### Wie verhindere ich, dass der Swipe mit dem vertikalen Scrollen kollidiert?

Setz touch-action: pan-y auf die Zeile, damit der Browser das vertikale Scrollen behält und horizontale Bewegungen an deinen Handler gibt, und sperr den Drag auf eine Achse, sobald er beginnt. Motion erledigt beides mit drag="x" und dragDirectionLock.

### Ist die List von Wingo UI kostenlos?

Nein. Die List mit Swipe Actions ist eine Pro-Komponente. Der Toast für das Undo und der Delete Button sind kostenlos, und npx wingo-ui@latest add list kopiert die List und alles, was sie importiert, sobald du dich mit einem Pro-Konto anmeldest.

---

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