Skip to content
WingoUI
ComponentsTemplatesDocsPricingAI agentsBlogChangelog
  1. Home
  2. Blog
  3. Mobile UI in React
  4. React Swipe Cards: A Tinder-Style Deck With Undo
Auf Deutsch lesen
  1. Blog
  2. Mobile UI in React
  3. React Swipe Cards: A Tinder-Style Deck With Undo
Mobile UI in React
Mobile UI in React

React Swipe Cards: A Tinder-Style Deck With Undo

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

On this page

0%
  1. What does a good swipe card interaction need?
  2. How do you build a swipeable card in React from scratch?
  3. How do you build a swipe deck with Wingo UI?
  4. How does undo work in a swipe deck?
  5. How do you add a third direction?
  6. How do people decide without swiping?
  7. Where does a listing card fit with swipe cards?
  8. Which swipe card mistakes show up in production?
  9. What should you install next?
  10. FAQ
    1. How do I make Tinder-style swipe cards in React?
    2. Why do my swipe cards lag on a phone?
    3. Does react-tinder-card work with React 19?
    4. How do I add undo to a swipe deck?
    5. Are swipe cards accessible?

TL;DR

React swipe cards work best when the card's position lives in a motion value and the tilt, the stamps and the cards behind it are derived from it, so React renders once per card instead of once per frame. Decide on release: past a quarter of the card width or on a flick faster than about 400px/s the card flies off with the speed of the throw, otherwise it springs back. Add an undo, round buttons and arrow keys so every decision also works without a swipe; the Wingo UI Swipe Deck ships all of it.

Published Oct 13, 2026

React swipe cards are the Tinder pattern outside of dating: a stack of cards, one decision at a time, swipe right to keep and left to pass. Teams use it to approve expenses, triage leads, review flashcards or browse rentals on a phone. The demos are easy to find; the hard parts are a drag that stays smooth on a mid-range Android phone, an undo that reverses the save, and a way to decide without swiping. This tutorial builds the card from scratch with motion, then shows the Wingo UI Swipe Deck, which adds stamps, a stack, undo, buttons and keyboard support.

What does a good swipe card interaction need?

Six things, in roughly the order people notice them:

  1. The card follows the finger and tilts. The tilt pivots around the bottom of the card, the way a card held at the bottom swings.
  2. A stamp shows where it is going. Like or Nope fades in on the side the card is heading for, with text and an icon so color is never the only cue.
  3. A clear threshold. Past a quarter of the card width, or with a quick flick, letting go decides. Anything less springs back.
  4. The throw keeps its speed. A hard flick leaves fast and a slow drag leaves slowly, so the card carries the momentum you gave it.
  5. The next card is already there. The cards behind rise into place while you drag, with their images loaded.
  6. Undo, buttons and keys. People swipe the wrong way, and some cannot swipe at all.

Check any package for swipeable cards against this list. Take react-tinder-card (opens in a new tab), a web package for the Tinder swipe: as of October 2026 it is at 1.6.4 (published November 7, 2023 (opens in a new tab), MIT). It lists React 16.8 to 18 as peer dependencies, so npm reports a peer conflict on React 19, needs @react-spring/web, offers swipe(dir) and restoreCard() on a ref, and its README does not mention keyboard support or screen readers.

How do you build a swipeable card in React from scratch?

Keep the position in a motion value and derive everything else from it. A swipeable card in React that stores x in state re-renders the card on every pointer move, and on a phone with a photo in the card that is where the lag comes from. With useMotionValue and useTransform, the tilt and the stamps update outside React, and the component renders once per card:

tsx
"use client";
import { useRef, type ReactNode } from "react";
import { animate, motion, useMotionValue, useTransform, type PanInfo } from "motion/react";
type Direction = "left" | "right";
export function SwipeCard({ children, onDecide }: { children: ReactNode; onDecide: (direction: Direction) => void }) {
const cardRef = useRef<HTMLDivElement>(null);
const x = useMotionValue(0);
// tilt and stamps follow x without a re-render
const rotate = useTransform(x, [-300, 300], [-12, 12]);
const like = useTransform(x, [20, 100], [0, 1]);
const nope = useTransform(x, [-100, -20], [1, 0]);
const onDragEnd = (_: unknown, info: PanInfo) => {
const width = cardRef.current?.offsetWidth ?? 320;
const flick = Math.abs(info.velocity.x) > 400;
if (!flick && Math.abs(x.get()) < width * 0.25) {
// not far enough: back to the middle, carrying the release speed
void animate(x, 0, { type: "spring", stiffness: 500, damping: 40, velocity: info.velocity.x });
return;
}
const direction: Direction = (flick ? info.velocity.x : x.get()) > 0 ? "right" : "left";
const target = (direction === "right" ? 1 : -1) * width * 1.5;
void animate(x, target, { duration: 0.3, ease: "easeOut" }).then(() => onDecide(direction));
};
return (
<motion.div
ref={cardRef}
drag="x"
dragMomentum={false}
onDragEnd={onDragEnd}
style={{ x, rotate, transformOrigin: "50% 100%" }}
className="relative aspect-[3/4] w-full max-w-sm cursor-grab overflow-hidden rounded-3xl bg-surface shadow-raised active:cursor-grabbing"
>
{children}
<motion.span style={{ opacity: like }} className="absolute top-6 left-6 -rotate-12 rounded-lg border-2 border-success px-2 font-bold text-success">
LIKE
</motion.span>
<motion.span style={{ opacity: nope }} className="absolute top-6 right-6 rotate-12 rounded-lg border-2 border-destructive px-2 font-bold text-destructive">
NOPE
</motion.span>
</motion.div>
);
}

Render it with key={item.id} so each new card starts at zero. With drag="x", Motion sets touch-action: pan-y, so a vertical swipe on the card still scrolls the page. The 400px/s flick and the quarter-width threshold are the same numbers Wingo UI's lib/motion uses for every swipe, sheet and drawer in the library, so a card and a bottom sheet complete at the same point.

That covers the card swipe animation. What the skeleton still lacks: the cards behind, a throw that carries the release speed (the 0.3s tween ignores it), a click that fires after a drag ends on a button inside the card, a haptic tick when the threshold arms, undo, buttons, keys and announcements. That list is the rest of this post.

How do you build a swipe deck with Wingo UI?

Pass items (each with a stable id), a renderCard and an onSwipe. The deck draws the card surface, the stack, the stamps, the round buttons and the undo button. Here is a rentals screen where Save keeps a listing and the saved ones collect in a grid below the deck (the SavedRentals grid comes later in this post):

tsx
"use client";
import { useState } from "react";
import Image from "next/image";
import { Heart, X } from "lucide-react";
import { SwipeDeck } from "@/components/ui/swipe-deck";
import { SavedRentals } from "./saved-rentals";
export type Rental = { id: string; title: string; area: string; rent: number; photo: string };
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
type DiscoverProps = {
rentals: Rental[];
save: (id: string) => Promise<void>;
unsave: (id: string) => Promise<void>;
};
export function DiscoverRentals({ rentals, save, unsave }: DiscoverProps) {
const [saved, setSaved] = useState<Rental[]>([]);
return (
// a thrown card flies past the deck, so clip the page sideways
<div className="overflow-x-clip px-4 py-6">
<SwipeDeck
items={rentals}
stack="fan"
rightTone="brand"
icons={{ left: <X />, right: <Heart /> }}
labels={{ left: "Pass", right: "Save", empty: `${saved.length} saved` }}
getItemLabel={(rental) => `${rental.title}, ${rental.area}`}
renderCard={(rental) => (
<div className="flex h-full flex-col">
<div className="relative min-h-0 flex-1">
<Image src={rental.photo} alt="" fill sizes="384px" className="object-cover" draggable={false} />
</div>
<div className="flex flex-col gap-1 p-5">
<span className="text-lg font-semibold">{rental.title}</span>
<span className="text-sm text-muted-foreground">
{rental.area} · {usd.format(rental.rent)}/mo
</span>
</div>
</div>
)}
onSwipe={async (rental, direction) => {
if (direction !== "right") return;
// a rejection brings the card back with an error
await save(rental.id);
// functional updates: the handler may run after other state changes
setSaved((prev) => [rental, ...prev]);
}}
onUndo={(rental, direction) => {
if (direction !== "right") return;
setSaved((prev) => prev.filter((item) => item.id !== rental.id));
void unsave(rental.id);
}}
/>
{saved.length > 0 && <SavedRentals className="mt-10" saved={saved} save={save} unsave={unsave} />}
</div>
);
}

onSwipe runs once the card has left. When it returns a promise, the deck waits for it before the next decision, and a rejection brings the card back with the error label ("Couldn't save. Try again."). The cards are drawn as a stack (the ones behind peek out below at 0.95 and 0.9 scale), a fan (they lean 3 degrees either way) or none. rotation sets the tilt at a full-width drag (12 degrees by default, 0 keeps it upright), and visibleCards sets how many are drawn, 3 by default, so the next photos start loading before they reach the top.

Drag the top receipt right to approve or left to reject, watch the stamp lock in past a quarter of the card, then press the circular arrow to undo.
$ npx wingo-ui@latest add swipe-deck
ProSwipe Deck docs

How does undo work in a swipe deck?

Undo in a swipe deck has two halves: the card comes back, and whatever the decision saved is reversed. The deck keeps an index of decided cards. Undo steps it back one, and the card flies in from the side it left. Three paths trigger it: the small circular arrow next to the round buttons, Backspace, and Cmd/Ctrl+Z. Each one calls onUndo(item, direction), so you reverse exactly what that decision saved; in the example above, only a Save needs undoing. showUndo={false} hides the button and turns the keys off.

To drive undo from your own UI, setApi hands you { swipe(direction), undo(), index }. And to control the deck from outside, pass index and onIndexChange; setting index to 0 starts over, which is how an empty state with a "Review again" button works.

How do you add a third direction?

Add "up" to directions for a Later choice. Its stamp sits at the bottom of the card and its round button is smaller and in the middle. Most apps should stop at three; diagonal or down swipes are hard to discover and easy to trigger by accident.

With three directions, swipe up to put a receipt off for later. The middle button does the same.
$ npx wingo-ui@latest add swipe-deck
ProSwipe Deck docs

Allowing up has a cost on a phone. With only left and right, the card has touch-action: pan-y, so a vertical swipe that starts on the card still scrolls the page. With up allowed, the card needs vertical moves too and uses touch-action: none, so the page cannot scroll from the card. Leave room above or below the deck to scroll, or keep two directions on screens that scroll. Our Tailwind touch manipulation guide explains which touch-action value each draggable surface needs.

How do people decide without swiping?

With the buttons and keys, which do the same thing with the same animation. 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. The deck provides:

  • Round 56px buttons under the deck for each direction (showActions, on by default). They tint and grow as a drag heads their way, which also teaches the gesture.
  • Keys. Tab to the card, then Left, Right (and Up when allowed) decide. Backspace or Cmd/Ctrl+Z undo. Focus follows to the next card, and to the empty state after the last one.
  • Announcements. The top card carries instructions for screen readers ("Swipe or use Left and Right arrows to decide. Backspace undoes."), and every decision is announced with what is left, such as "Hilton Boston, $412.00: Accept. 2 left." when you set getItemLabel.
  • Clickable content. Buttons, links and inputs inside a card never start a drag, so a "View details" button in the card still works.
  • Reduced motion. The card does not tilt and leaves with a short fade and slide.

On phones the drag arms with a medium haptic tick, commits with a light one and plays an error pattern when a save fails. Treat that as a bonus: as of October 2026, caniuse (opens in a new tab) shows no navigator.vibrate in Safari. Our Web Vibration API guide covers the gaps.

Where does a listing card fit with swipe cards?

After the deck, not inside it. A card in a deck should be one big drag surface. The Listing card is built for grids: its photos are a carousel you swipe sideways, and its title and photos are links. Inside a deck, those gestures compete with the card swipe, and the deck refuses to start a drag on a link. So use the deck to decide and the Listing card to show what was kept, in a two-column phone grid:

tsx
import { ListingCard } from "@/components/ui/listing-card";
import { cn } from "@/lib/utils";
import type { Rental } from "./discover-rentals";
type SavedRentalsProps = {
saved: Rental[];
save: (id: string) => Promise<void>;
unsave: (id: string) => Promise<void>;
className?: string;
};
export function SavedRentals({ saved, save, unsave, className }: SavedRentalsProps) {
return (
<div className={cn("grid grid-cols-2 gap-3", className)}>
{saved.map((rental) => (
<ListingCard
key={rental.id}
size="sm"
locale="en-US"
// a rejected promise rolls the heart back and announces the error
onFavoriteChange={(favorite) => (favorite ? save(rental.id) : unsave(rental.id))}
listing={{
id: rental.id,
title: rental.title,
href: `/rentals/${rental.id}`,
images: [rental.photo],
price: rental.rent,
currency: "USD",
priceSuffix: "/mo",
location: rental.area,
favorite: true,
}}
/>
))}
</div>
);
}

size="sm" is the size meant for two-column phone grids. favorite: true starts each heart filled, and onFavoriteChange lets people unsave from the grid and save again if they change their mind: return the promise and the heart stays in its new state while it is pending, or rolls back if the request fails.

Listing cards in a two-column phone grid, the layout for the kept rentals: swipe through the photos, tap the heart, or open a listing.
$ npx wingo-ui@latest add listing-card
ProListing card docs

Which swipe card mistakes show up in production?

  • State per frame. Calling setState in onDrag re-renders the card on every move. Keep motion in motion values; the deck only sets state when a direction arms or a card is decided.
  • A card that escapes the page. A thrown card flies past the deck's edges and can create a horizontal scrollbar. Wrap the screen in overflow-x-clip.
  • Saving before the card leaves. Save in onSwipe, which runs after the card has gone, and return the promise so a failure brings it back instead of losing the decision.
  • Undo that only moves the card. If Save wrote to the server, undo must unsave. onUndo gives you the item and the direction for exactly that.
  • Stamps that rely on color. Green and red stamps alone fail for color-blind users. Keep the text and the icon.

Swipe rows are the smaller cousin of swipe cards; our React swipe to delete tutorial uses the same motion values for list rows. For the rest of the mobile layer, read our guide to mobile-first React components or browse Mobile UI in React.

What should you install next?

npx wingo-ui@latest add swipe-deck copies the Swipe Deck with the Button, the Empty State and the haptics, motion and state helpers it imports, and installs motion and lucide-react. Add the Listing card for the saved grid with npx wingo-ui@latest add listing-card. Both are part of Wingo UI Pro, so sign in first with npx wingo-ui@latest login.

Components in this post

  • Swipe Deck

    A React swipe card deck: decide on one card at a time, swipe right to accept, left to reject, optionally up for later, with undo.

    Pro
  • Listing card

    A React listing card for marketplaces: swipeable photos, price, place and time, badges and a heart, in grid, list and compact layouts.

    Pro

FAQ

How do I make Tinder-style swipe cards in React?

Drag the top card horizontally on a motion value, derive the rotation and the Like and Nope stamps from that value with useTransform, and on release either throw the card off screen or spring it back. Render the next cards behind it so one is always ready.

Why do my swipe cards lag on a phone?

Usually because the drag position is stored in React state, which re-renders the card and everything in it on every pointer move. Keep the position in a motion value or a ref and only set state when the card is decided.

Does react-tinder-card work with React 19?

Its latest release, 1.6.4 from November 2023, lists React 16.8 to 18 as peer dependencies, so npm reports a peer conflict on React 19. It also needs @react-spring/web, and its README does not mention keyboard support.

How do I add undo to a swipe deck?

Keep the index of the top card and the direction of each decision. Undo decrements the index, reverses whatever the decision saved, and animates the card back in from the side it left. The Wingo UI Swipe Deck does this with a button, Backspace or Cmd/Ctrl+Z, and calls onUndo with the card and its direction.

Are swipe cards accessible?

Only with buttons or keys for every direction, because a swipe is a dragging movement under WCAG 2.2 success criterion 2.5.7. The deck should also announce each decision and move focus to the next card.

  • Mobile UI
  • Swipe cards
  • Gestures
  • Motion
  • React

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
Build it: screens and apps

React Marketplace Template That Works Like an App

Build a react marketplace template on Next.js: a what and where search, facets in the URL, listing cards and a detail page that behave like an app on phones.

SRSerban Rusu·Oct 16, 2026·15 min read
Mobile UI in React

React Action Sheet: iOS-Style, Async and Responsive

Build a React action sheet like iOS: red destructive rows, a separate Cancel, an awaitable show() that waits for async actions, and a dialog from 768px.

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

React Collapsing Header: iOS Large Title and Photo Hero

Build a react collapsing header: the iOS large title handover and a parallax photo hero, driven by scroll motion values instead of a re-render per frame.

SRSerban Rusu·Oct 16, 2026·8 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