Carousels

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.en

New project or installation trouble? Start here →

Swipe 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.

100
500
Active edited usageDefault props
<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.json

Or, 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

PropTypeDefaultDescription
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
emptyStateReactNodedefault messageThe content shown once everything has been swiped
thresholdnumber100Horizontal offset threshold that triggers the fly-out (px)
velocityThresholdnumber500Flick velocity threshold that triggers the fly-out (px/s)
classNamestring—Stack container styling (usually where you set the height)

Ref (SwipeCardsHandle)

MethodTypeDescription
swipe(dir: "left" | "right") => voidProgrammatically 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", with useTransform mapping 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, carrying role="group" and an aria-label explaining the interaction.
  • The cards behind are hidden from assistive technology with aria-hidden and have pointer-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.