互動編排

扇形手牌

像手中持牌般扇形展開的卡片組:滑過抽起、鄰卡讓位,往上拖曳或快甩即出牌飛到牌堆,卡面可放任意內容。

把一組卡片排成手中持牌的扇形:滑過(或用鍵盤選中)的牌會抽起、左右鄰卡往外讓位;把牌往上拖曳或快甩,它就飛到元件上方的牌堆,散亂地疊在先前出的牌上,手中剩下的牌以同一組 spring 重新排好。卡面可放任意節點,另附一個簡易的 PlayingCard 撲克牌子元件;也很適合做「把方案打出去即選定」這類帶點遊戲感的選擇器。已出牌清單支援受控/非受控雙模式。

把這個效果裝進你的專案

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

npx webberui@latest add fanned-cards

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

Playground

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

7
58
340
30
編輯中的呼叫預設屬性
<FannedCards/>

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

安裝

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

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

使用

import * as React from "react";
import {
  FannedCards,
  PlayingCard,
  type FannedCardsHandle,
} from "@/components/ui/fanned-cards";

const items = [
  { id: "spade-A", node: <PlayingCard rank="A" suit="spade" /> },
  { id: "heart-7", node: <PlayingCard rank="7" suit="heart" /> },
  { id: "club-K", node: <PlayingCard rank="K" suit="club" /> },
];

export function Hand() {
  const [played, setPlayed] = React.useState<string[]>([]);
  return (
    <>
      <FannedCards
        items={items}
        playedIds={played}
        onPlayedChange={setPlayed}
        onPlay={(id) => console.log(`出牌:${id}`)}
      />
      <button type="button" onClick={() => setPlayed([])}>
        重新發牌
      </button>
    </>
  );
}

// 非受控:用 ref.redeal() 清空牌堆
export function UncontrolledHand() {
  const ref = React.useRef<FannedCardsHandle>(null);
  return (
    <>
      <FannedCards ref={ref} items={items} />
      <button type="button" onClick={() => ref.current?.redeal()}>
        重新發牌
      </button>
    </>
  );
}

Props

Prop型別預設值說明
itemsFannedCardItem[]—手牌清單,依陣列順序由左到右排成扇形
playedIdsstring[]—受控的已出牌 id(依出牌先後排列);未提供時由元件內部管理
defaultPlayedIdsstring[][]非受控模式的初始已出牌 id
onPlayedChange(ids: string[]) => void—已出牌清單變動時回呼(出牌或重新發牌)
onPlay(id: string) => void—某張牌被打出時回呼
spacingnumber58相鄰兩張牌的水平間距基準(px);手中超過 5 張時再收 10px,最小 26
springConfigFannedCardsSpring{ stiffness: 340, damping: 30 }扇形重排與出牌飛行的 spring 參數
cardWidthnumber112卡片寬度(px)
cardHeightnumber160卡片高度(px)
disabledbooleanfalse停用抽牌、拖曳與鍵盤出牌
listLabelstring"手牌"手牌清單(listbox)的無障礙名稱
refReact.Ref<FannedCardsHandle>—取得 redeal() 等程式化操作
classNamestring—透傳到最外層容器

FannedCardItem

欄位型別預設值說明
idstring—唯一識別碼,出牌狀態以此記錄
nodeReact.ReactNode—卡面內容,可放任意節點
labelstring—給螢幕閱讀器的名稱;未提供且卡面是 PlayingCard 時自動產生(例如「紅心 7」)

FannedCardsSpring

欄位型別預設值說明
stiffnessnumber340彈簧剛性,越大越快到位
dampingnumber30阻尼,越大越不晃
massnumber—質量,越大越沉

FannedCardsHandle

欄位型別預設值說明
redeal() => void—重新發牌:非受控時清空已出的牌;受控時呼叫 onPlayedChange([]),由父層清空

PlayingCard

欄位型別預設值說明
rank"A" | "2" | … | "10" | "J" | "Q" | "K"—點數
suit"spade" | "heart" | "diamond" | "club"—花色(紅心、方塊為紅色,黑桃、梅花為黑色)
faceDownbooleanfalse蓋牌,顯示菱格紋背面
classNamestring—透傳到卡片根元素

另外具名匯出 describePlayingCard(rank, suit),回傳「紅心 7」這類朗讀名稱。

細節

  • 扇形公式:手中剩 n 張時,第 i 張的 offset 為 i − (n − 1) / 2,角度步進為 min(9°, 46° / (n − 1)),下沉量為 |角度| × 1.9,水平位移為 offset × 間距;旋轉軸在卡片底部中央。
  • 抽起的牌升高 54px、旋轉縮為原本的 30% 並移到最上層,左右鄰卡各往外讓出 0.35 倍間距。
  • 出牌判定:往上拖超過 80px,或往上甩的速度超過 600px/s。拖曳只能往上,放手未出牌會彈回原位。
  • 牌堆在手牌上方 1.4 倍卡高處(預設 224px);每張牌的散亂角度(±13°)與偏移在出牌當下產生並保存,受控模式下父層直接塞入的 id 則用 id 的 hash 產生穩定的角度。
  • spring 參數以 springConfig 外露;牌背底色可用 --wb-fanned-cards-back 覆寫。
  • PlayingCard 的花色圖形為自繪 SVG,J/Q/K 只放大字母與花色,不畫人頭。

可及性

  • 手牌是 role="listbox"、每張牌是 role="option" 並帶 aria-selected,採 roving tabindex,Tab 只會停在目前選中的那張
  • ←/→ 移動選取(同時套用抽起效果)、Home/End 跳到兩端、Enter 或空白鍵出牌;出牌後焦點移到相鄰的牌,按 Enter 有 IME 組字保護
  • 出牌與重新發牌會在 aria-live 區域宣告,例如「已出牌:紅心 7」;名稱取自 label,卡面是 PlayingCard 時自動產生
  • 已在牌堆的牌對輔助科技隱藏,不會被誤選
  • 使用者系統開啟「減少動態效果」時,扇形不做 spring 過渡、直接到位;出牌不飛行,改為該卡原地淡出 150ms 後出現在牌堆