輪播

Swipe Cards

Tinder 式滑卡堆疊,可拖曳、依速度飛出,並提供程式化左右滑與空狀態。

把這個效果裝進你的專案

在專案資料夾執行指令,CLI 會加入原始碼與依賴套件,再依下方使用範例引用元件。

npx webberui@latest add swipe-cards

還沒有專案,或安裝遇到問題?看快速開始 →

Swipe Cards 是用 React、Tailwind CSS 與 Motion 做的 Tinder 式滑卡(swipe cards)堆疊元件:最上層卡片可以用滑鼠或觸控左右拖曳,位移超過閾值或甩動夠快就飛出,另外也能透過 ref 與方向鍵操作。

Playground

即時調整 props、程式碼片段同步更新——直接試出你要的樣子再複製。

100
500
編輯中的呼叫預設屬性
<SwipeCards/>

完整範例包含預覽的排版與資料。先安裝元件,再把範例檔貼入你的專案。

適用情境

  • 新手引導時讓使用者快速挑選興趣主題,作為之後推薦內容的依據。
  • 求職、租屋、配對這類逐筆瀏覽並決定「要/不要」的流程。
  • 內容審核或標註工具,一次處理一張卡片的通過與退回。
  • 產品回饋或功能投票,讓使用者對候選功能逐一表態。
  • 不適合:清單列上的左滑刪除、封存等動作,改用 Swipe Action List。

安裝

npx webberui@latest add https://webberui.com/r/swipe-cards.json

或在 components.json 設定 registries 後,改用 @webberui/swipe-cards 安裝。

使用

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"
/>;

// 程式化左右滑
<button onClick={() => ref.current?.swipe("left")}>略過</button>
<button onClick={() => ref.current?.swipe("right")}>喜歡</button>

最上層卡片可用滑鼠或觸控往左右拖曳,x 位移會即時映射成旋轉角度與「喜歡/略過」提示;放開時若位移超過 threshold 或甩動速度超過 velocityThreshold,卡片會朝該方向飛出並露出下一張。全部滑完後顯示 emptyState。

範例

收集使用者偏好

把 cards 定義在元件外面或用 useMemo,再用 onSwipe 記錄右滑的項目,全部滑完時在 emptyState 顯示結果。

import * as React from "react";
import { SwipeCards, type SwipeDirection } from "@/components/ui/swipe-cards";

const topics = [
  { id: "design", title: "介面設計" },
  { id: "frontend", title: "前端開發" },
  { id: "data", title: "資料分析" },
  { id: "pm", title: "產品管理" },
];

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} 個主題</p>}
    />
  );
}

重新開始一輪

元件在 cards 陣列參照改變時把進度歸零,所以換一個新陣列就能重新開始,也能用來載入下一批資料。

const [deck, setDeck] = React.useState(() => createCards(listings));

<SwipeCards
  cards={deck}
  className="h-96"
  emptyState={<button onClick={() => setDeck(createCards(listings))}>重新瀏覽</button>}
/>

調整滑動靈敏度

降低 threshold 與 velocityThreshold,小幅拖曳或輕甩就能讓卡片飛出,適合卡片寬度較窄的手機版面。

<SwipeCards cards={cards} threshold={60} velocityThreshold={300} className="h-72" />

Props

Prop型別預設值說明
cards{ id: string | number; content: ReactNode }[]—卡片陣列,第一項在最上層
onSwipe(id, dir: "left" | "right") => void—滑掉一張卡時觸發
emptyStateReactNode預設提示全部滑完後顯示的內容
thresholdnumber100觸發飛出的水平位移閾值(px)
velocityThresholdnumber500觸發飛出的甩動速度閾值(px/s)
classNamestring—堆疊容器樣式(通常在此指定高度)

Ref(SwipeCardsHandle)

方法型別說明
swipe(dir: "left" | "right") => void以程式方式滑掉最上層卡片,可用於自訂按鈕

細節

  • 後方卡片依深度逐層縮小並下移,露出邊緣形成堆疊感;同時最多渲染 3 層。
  • 拖曳採 drag="x",useTransform 將位移映射為旋轉角度;飛出使用 spring,離場後才更新索引並讓下一張補位。
  • 容器高度建議透過 className 指定(例如 h-60),卡片內容自行決定寬高;容器不裁切,飛出的卡片才能完整離場。

可及性

  • 最上層卡片可聚焦(tabIndex=0),支援 → 喜歡、← 略過,並帶有 role="group" 與操作說明的 aria-label。
  • 後方卡片以 aria-hidden 對輔助科技隱藏,且 pointer-events: none,只有最上層可互動。
  • 使用者系統開啟「減少動態效果」時,停用拖曳與旋轉,改以按鈕/鍵盤觸發淡出切換。

常見問題

為什麼卡片每次重新渲染都回到第一張?

元件在 cards 參照改變時會重置進度。如果在 JSX 裡直接寫陣列字面值,父層每次重新渲染都會產生新陣列;請把陣列放在元件外、存在 state,或用 useMemo 包起來。

可以在 Next.js App Router 使用嗎?

可以。原始碼開頭已有 "use client",在 Server Component 頁面直接 import 即可;資料可以在伺服器端取得後當成 props 傳給一個用戶端元件,再組成 cards。

onSwipe 什麼時候觸發?

卡片飛出的動畫結束後才觸發,帶入該卡的 id 與方向("left" 或 "right"),之後下一張補位。用 ref 呼叫 swipe("left") 或 swipe("right") 也會走同一個流程。

鍵盤與減少動態效果怎麼處理?

最上層卡片可以聚焦,右方向鍵表示喜歡、左方向鍵表示略過;後方卡片以 aria-hidden 隱藏。系統開啟「減少動態效果」時停用拖曳與旋轉,改用按鈕或鍵盤以淡出切換。

同時會渲染幾張卡片?

最多 3 張,後方卡片依深度縮小並下移。容器不會裁切內容,建議用 className 指定高度,卡片尺寸由 content 自己決定。