卡片與網格

Card Deck

可拖曳抽牌的堆疊卡組,四向甩牌、自動補位,支援無限循環或抽完見底兩種模式。

把這個效果裝進你的專案

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

npx webberui@latest add card-deck

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

Card Deck 是用 React、Tailwind CSS 與 Motion(motion/react)做的可拖曳堆疊卡片(card stack)元件:最上層卡片可朝上下左右四個方向甩出,下一張以 spring 補位,後排卡片帶有穩定的堆疊傾角。

Playground

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

3
96
500
編輯中的呼叫預設屬性
<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型別預設值說明
childrenReact.ReactNode—每個直接子節點是一張卡片,第一項為初始最上層
indexnumber—受控的最上層卡片索引
defaultIndexnumber0非受控模式的初始最上層索引
onIndexChange(index: number) => void—最上層卡片變更時觸發
onDraw(index: number, direction: DrawDirection) => void—抽掉一張卡時觸發,帶入被抽卡片索引與方向
loopbooleantruetrue 抽後循環回牌底;false 抽完顯示空狀態
visibleCountnumber3同時堆疊渲染的層數(含最上層)
drawThresholdnumber96觸發抽牌的拖曳位移閾值(px)
velocityThresholdnumber500觸發抽牌的拖曳甩動速度閾值(px/s)
emptyStateReact.ReactNode—loop={false} 抽完後顯示的內容
cardClassNamestring—套用到每張卡片外觀表面的樣式
classNamestring—套用到牌組容器

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/ 即可。