Card Deck
可拖曳抽牌的堆疊卡組,四向甩牌、自動補位,支援無限循環或抽完見底兩種模式。
把這個效果裝進你的專案
在專案資料夾執行指令,CLI 會加入原始碼與依賴套件,再依下方使用範例引用元件。
npx webberui@latest add card-deckCard Deck 是用 React、Tailwind CSS 與 Motion(motion/react)做的可拖曳堆疊卡片(card stack)元件:最上層卡片可朝上下左右四個方向甩出,下一張以 spring 補位,後排卡片帶有穩定的堆疊傾角。
Playground
即時調整 props、程式碼片段同步更新——直接試出你要的樣子再複製。
<CardDeck/>
完整範例包含預覽的排版與資料。先安裝元件,再把範例檔貼入你的專案。
適用情境
- 單字卡、題庫練習:一次專注一張,抽完一輪顯示完成畫面。
- 新手引導或功能介紹:把三到五個重點做成一疊卡片,讓使用者自己翻。
- 首頁或活動頁的互動展示區,例如輪流抽出作品、客戶案例或產品特色。
- 行動裝置上的卡片瀏覽,用拖曳取代左右箭頭按鈕。
- 不適合:需要「喜歡/略過」這類左右二選一的判斷流程時,改用 Swipe Cards。
安裝
npx webberui@latest add https://webberui.com/r/card-deck.json或在 components.json 設定 registries 後,改用 @webberui/card-deck 安裝。
使用
每個直接子節點就是一張卡片,第一項為初始最上層。容器需要有明確尺寸(卡片以絕對定位相互堆疊)。
import { CardDeck } from "@/components/ui/card-deck";
<div className="h-72 w-64">
<CardDeck className="h-full w-full">
<div className="flex h-full items-center justify-center">卡片一</div>
<div className="flex h-full items-center justify-center">卡片二</div>
<div className="flex h-full items-center justify-center">卡片三</div>
</CardDeck>
</div>程式化抽牌
透過 ref 取得 draw() / reset(),方向鍵盤與拖曳之外也能用按鈕觸發。
import { CardDeck, type CardDeckHandle } from "@/components/ui/card-deck";
const deckRef = React.useRef<CardDeckHandle>(null);
<CardDeck ref={deckRef}>{/* ... */}</CardDeck>
<button onClick={() => deckRef.current?.draw("left")}>抽牌</button>抽完見底
設定 loop={false} 時,卡片會被逐張抽掉,抽完後顯示 emptyState。
<CardDeck loop={false} emptyState={<p>沒有更多了</p>}>
{/* ... */}
</CardDeck>範例
受控模式與進度顯示
傳入 index 與 onIndexChange 由外部管理目前的最上層卡片,就能在牌組旁顯示進度。
"use client";
import * as React from "react";
import { CardDeck } from "@/components/ui/card-deck";
const tips = ["建立專案", "邀請成員", "連結 GitHub", "設定通知"];
export function OnboardingDeck() {
const [index, setIndex] = React.useState(0);
return (
<div className="flex flex-col items-center gap-4">
<div className="h-72 w-64">
<CardDeck index={index} onIndexChange={setIndex} className="h-full w-full">
{tips.map((tip) => (
<div key={tip} className="flex h-full items-center justify-center p-6 text-lg font-medium">
{tip}
</div>
))}
</CardDeck>
</div>
<p className="text-sm text-neutral-500">
第 {index + 1} / {tips.length} 張
</p>
</div>
);
}依抽牌方向記錄單字卡結果
onDraw 會帶入被抽卡片的索引與方向,可以把向右當成「記得」、向左當成「再複習」;搭配 loop={false} 與 ref.reset() 做出一輪練習。
"use client";
import * as React from "react";
import { CardDeck, type CardDeckHandle, type DrawDirection } from "@/components/ui/card-deck";
const words = ["ephemeral", "ubiquitous", "pragmatic", "meticulous"];
export function FlashcardDeck() {
const deckRef = React.useRef<CardDeckHandle>(null);
const [review, setReview] = React.useState<string[]>([]);
function handleDraw(index: number, direction: DrawDirection) {
if (direction === "left") setReview((list) => [...list, words[index]]);
}
return (
<div className="h-72 w-64">
<CardDeck
ref={deckRef}
loop={false}
onDraw={handleDraw}
className="h-full w-full"
emptyState={
<div className="flex h-full flex-col items-center justify-center gap-3 text-sm">
<p>本輪完成,待複習 {review.length} 個</p>
<button
onClick={() => {
setReview([]);
deckRef.current?.reset();
}}
>
重新開始
</button>
</div>
}
>
{words.map((word) => (
<div key={word} className="flex h-full items-center justify-center text-xl">
{word}
</div>
))}
</CardDeck>
</div>
);
}調整卡片外觀與手感
cardClassName 套用到每張卡片表面;visibleCount 控制後排疊幾層,drawThreshold 與 velocityThreshold 調高後需要拖得更遠或甩得更快才會抽出。
<CardDeck
visibleCount={4}
drawThreshold={140}
velocityThreshold={800}
cardClassName="rounded-3xl border-0 bg-neutral-900 text-white"
className="h-80 w-72"
>
{/* ... */}
</CardDeck>Props
| Prop | 型別 | 預設值 | 說明 |
|---|---|---|---|
children | React.ReactNode | — | 每個直接子節點是一張卡片,第一項為初始最上層 |
index | number | — | 受控的最上層卡片索引 |
defaultIndex | number | 0 | 非受控模式的初始最上層索引 |
onIndexChange | (index: number) => void | — | 最上層卡片變更時觸發 |
onDraw | (index: number, direction: DrawDirection) => void | — | 抽掉一張卡時觸發,帶入被抽卡片索引與方向 |
loop | boolean | true | true 抽後循環回牌底;false 抽完顯示空狀態 |
visibleCount | number | 3 | 同時堆疊渲染的層數(含最上層) |
drawThreshold | number | 96 | 觸發抽牌的拖曳位移閾值(px) |
velocityThreshold | number | 500 | 觸發抽牌的拖曳甩動速度閾值(px/s) |
emptyState | React.ReactNode | — | loop={false} 抽完後顯示的內容 |
cardClassName | string | — | 套用到每張卡片外觀表面的樣式 |
className | string | — | 套用到牌組容器 |
DrawDirection 為 "up" | "down" | "left" | "right"。
ref 會取得 CardDeckHandle:draw(direction?) 抽掉最上層卡片(預設向上),reset() 回到第一張。
細節
- 最上層卡片可自由拖曳(四個方向),位移或甩動速度超過閾值即朝該方向飛出,未達門檻則彈回原位。
- 拖曳時卡片會依水平位移量輕微傾斜;後排卡片以索引雜湊出穩定的堆疊傾角,看起來像自然疊放的一落牌。
- 抽牌後下一張自動 spring 補位到最前,牌組後方遞補新卡;
loop開啟時被抽的卡循環回牌底。 - 受控(傳入
index+onIndexChange)與非受控(defaultIndex)雙模式;子節點數量變動時非受控索引會自動夾回合法範圍。
可及性
- 最上層卡片可聚焦(
tabIndex),方向鍵對應四向抽牌,Enter/Space 向上抽出。 - 牌組容器帶
role="group"與aria-roledescription,並以aria-live區域朗讀目前張數/剩餘張數。 - 使用者系統開啟「減少動態效果」時,停用拖曳與飛出位移,抽牌改為淡出,避免大幅度動態。
常見問題
可以在 Next.js App Router 使用嗎?
可以。元件檔案開頭已經有 "use client",在 Server Component 的頁面中直接 import 使用即可;如果要用 ref 或 useState 控制牌組,包住它的那一層才需要是 Client Component。
為什麼卡片沒有顯示或高度是 0?
卡片以絕對定位(absolute inset-0)互相堆疊,牌組本身不會撐出高度。請給 CardDeck 或它的外層容器明確的寬高,例如 className="h-72 w-64"。
拖曳太敏感或太難抽出,怎麼調整?
調整 drawThreshold(拖曳位移,預設 96 px)與 velocityThreshold(甩動速度,預設 500 px/s),任一項超過就會抽牌,兩項都未達到時卡片彈回原位。
系統開啟「減少動態效果」時會怎樣?
元件透過 Motion 的 useReducedMotion 偵測此設定,開啟時停用拖曳與飛出位移,後排傾角歸零,抽牌改為淡出;方向鍵、Enter/Space 與 ref.draw() 仍可抽牌。
需要哪些依賴?不用 CLI 可以嗎?
依賴是 motion、clsx 與 tailwind-merge。不用 CLI 的話,切到安裝區塊的「手動」分頁,安裝依賴後複製原始碼到 components/ui/ 即可。