Swipe Cards
A Tinder-style swipe card stack that can be dragged, flies out by velocity, and offers programmatic left/right swipes and an empty state.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add swipe-cards.enSwipe Cards is a React Tinder-style swipe cards stack built with Tailwind CSS and Motion. Drag the top card left or right with a mouse or touch; once it passes the distance threshold or is flicked fast enough it flies out, and you can also drive it through a ref or the arrow keys.
Playground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<SwipeCards/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
When to use
- Onboarding that asks users to pick topics quickly, feeding later recommendations.
- Job, rental or matching flows where people browse one item at a time and decide yes or no.
- Moderation or labeling tools that approve or reject one card at a time.
- Product feedback or feature voting, where users react to each candidate feature.
- Not a good fit: swipe-to-delete or archive actions on list rows; use Swipe Action List instead.
Installation
npx webberui@latest add https://webberui.com/r/swipe-cards.en.jsonOr, once registries are configured in components.json, install it as @webberui/swipe-cards.
Usage
import * as React from "react";
import {
SwipeCards,
type SwipeCardsHandle,
} from "@/components/ui/swipe-cards";
const ref = React.useRef<SwipeCardsHandle>(null);
<SwipeCards
ref={ref}
cards={[
{ id: 1, content: <div className="h-52 rounded-2xl bg-sky-500" /> },
{ id: 2, content: <div className="h-52 rounded-2xl bg-rose-500" /> },
]}
onSwipe={(id, dir) => console.log(id, dir)}
className="h-60 w-72"
/>;
// Programmatic left/right swipes
<button onClick={() => ref.current?.swipe("left")}>Skip</button>
<button onClick={() => ref.current?.swipe("right")}>Like</button>The topmost card can be dragged left or right with the mouse or by touch, and the x offset maps live to a rotation angle and a "like / skip" cue. On release, if the offset exceeds threshold or the flick velocity exceeds velocityThreshold, the card flies out in that direction and reveals the next one. Once everything has been swiped, emptyState is shown.
Examples
Collect preferences
Define cards outside the component or with useMemo, record right swipes in onSwipe, and show the result in emptyState once the deck is done.
import * as React from "react";
import { SwipeCards, type SwipeDirection } from "@/components/ui/swipe-cards";
const topics = [
{ id: "design", title: "Interface design" },
{ id: "frontend", title: "Frontend development" },
{ id: "data", title: "Data analysis" },
{ id: "pm", title: "Product management" },
];
const cards = topics.map((t) => ({
id: t.id,
content: (
<div className="flex h-64 w-64 items-center justify-center rounded-2xl border bg-white text-lg font-medium shadow-sm dark:bg-neutral-900">
{t.title}
</div>
),
}));
export function TopicPicker() {
const [liked, setLiked] = React.useState<string[]>([]);
return (
<SwipeCards
cards={cards}
className="h-80"
onSwipe={(id, dir: SwipeDirection) => {
if (dir === "right") setLiked((prev) => [...prev, String(id)]);
}}
emptyState={<p className="text-sm">{liked.length} topics selected</p>}
/>
);
}Start a new round
The component resets its progress when the cards array reference changes, so passing a new array starts over; the same works for loading the next batch.
const [deck, setDeck] = React.useState(() => createCards(listings));
<SwipeCards
cards={deck}
className="h-96"
emptyState={<button onClick={() => setDeck(createCards(listings))}>Browse again</button>}
/>Tune swipe sensitivity
Lower threshold and velocityThreshold so a short drag or a light flick sends the card away, which helps on narrow mobile layouts.
<SwipeCards cards={cards} threshold={60} velocityThreshold={300} className="h-72" />Props
| Prop | Type | Default | Description |
|---|---|---|---|
cards | { id: string | number; content: ReactNode }[] | — | The array of cards; the first item sits on top |
onSwipe | (id, dir: "left" | "right") => void | — | Fires when a card is swiped away |
emptyState | ReactNode | default message | The content shown once everything has been swiped |
threshold | number | 100 | Horizontal offset threshold that triggers the fly-out (px) |
velocityThreshold | number | 500 | Flick velocity threshold that triggers the fly-out (px/s) |
className | string | — | Stack container styling (usually where you set the height) |
Ref (SwipeCardsHandle)
| Method | Type | Description |
|---|---|---|
swipe | (dir: "left" | "right") => void | Programmatically swipes away the topmost card, useful for custom buttons |
How it works
- The cards behind shrink and shift down layer by layer with depth, exposing their edges to create the sense of a stack; at most 3 layers are rendered at once.
- The drag uses
drag="x", withuseTransformmapping the offset to a rotation angle; the fly-out uses a spring, and the index only updates after the exit so the next card moves up. - Set the container height through
className(h-60, say), and let the card content decide its own width and height; the container does not clip, so the flying card can exit completely.
Accessibility
- The topmost card is focusable (
tabIndex=0) and supports → to like and ← to skip, carryingrole="group"and anaria-labelexplaining the interaction. - The cards behind are hidden from assistive technology with
aria-hiddenand havepointer-events: none, so only the topmost one is interactive. - When the user has "reduce motion" enabled at the system level, the drag and the rotation are disabled, switching instead to a fade out triggered by button or keyboard.
FAQ
Why does the deck jump back to the first card on every render?
The component resets its progress when the cards reference changes. An array literal written inline in JSX is a new array on every parent render, so keep it outside the component, in state, or wrapped in useMemo.
Does it work with the Next.js App Router?
Yes. The source starts with "use client", so import it from a Server Component page as usual. Fetch data on the server, pass it as props to a client component, and build cards there.
When does onSwipe fire?
After the fly-out animation finishes, with the card's id and the direction ("left" or "right"); then the next card moves up. Calling swipe("left") or swipe("right") through the ref follows the same path.
How are keyboard and reduced motion handled?
The top card is focusable: Right arrow likes and Left arrow skips, and the cards behind it are hidden with aria-hidden. With "reduce motion" enabled, dragging and rotation are turned off and buttons or keys switch cards with a fade.
How many cards render at once?
Up to 3, with the ones behind scaled down and shifted by depth. The container does not clip, so set its height with className and let each card's content decide its own size.
Perspective Carousel
A 3D perspective carousel (coverflow style) with drag to page, looping, autoplay, and controlled and uncontrolled modes.
Cover Carousel
A horizontal carousel of cover cards whose indicator is driven continuously (scrubbed) by the scroll ratio, with full keyboard navigation and ARIA.