互動編排
扇形手牌
像手中持牌般扇形展開的卡片組:滑過抽起、鄰卡讓位,往上拖曳或快甩即出牌飛到牌堆,卡面可放任意內容。
把一組卡片排成手中持牌的扇形:滑過(或用鍵盤選中)的牌會抽起、左右鄰卡往外讓位;把牌往上拖曳或快甩,它就飛到元件上方的牌堆,散亂地疊在先前出的牌上,手中剩下的牌以同一組 spring 重新排好。卡面可放任意節點,另附一個簡易的 PlayingCard 撲克牌子元件;也很適合做「把方案打出去即選定」這類帶點遊戲感的選擇器。已出牌清單支援受控/非受控雙模式。
把這個效果裝進你的專案
在專案資料夾執行指令,CLI 會加入原始碼與依賴套件,再依下方使用範例引用元件。
npx webberui@latest add fanned-cardsPlayground
即時調整 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 | 型別 | 預設值 | 說明 |
|---|---|---|---|
items | FannedCardItem[] | — | 手牌清單,依陣列順序由左到右排成扇形 |
playedIds | string[] | — | 受控的已出牌 id(依出牌先後排列);未提供時由元件內部管理 |
defaultPlayedIds | string[] | [] | 非受控模式的初始已出牌 id |
onPlayedChange | (ids: string[]) => void | — | 已出牌清單變動時回呼(出牌或重新發牌) |
onPlay | (id: string) => void | — | 某張牌被打出時回呼 |
spacing | number | 58 | 相鄰兩張牌的水平間距基準(px);手中超過 5 張時再收 10px,最小 26 |
springConfig | FannedCardsSpring | { stiffness: 340, damping: 30 } | 扇形重排與出牌飛行的 spring 參數 |
cardWidth | number | 112 | 卡片寬度(px) |
cardHeight | number | 160 | 卡片高度(px) |
disabled | boolean | false | 停用抽牌、拖曳與鍵盤出牌 |
listLabel | string | "手牌" | 手牌清單(listbox)的無障礙名稱 |
ref | React.Ref<FannedCardsHandle> | — | 取得 redeal() 等程式化操作 |
className | string | — | 透傳到最外層容器 |
FannedCardItem
| 欄位 | 型別 | 預設值 | 說明 |
|---|---|---|---|
id | string | — | 唯一識別碼,出牌狀態以此記錄 |
node | React.ReactNode | — | 卡面內容,可放任意節點 |
label | string | — | 給螢幕閱讀器的名稱;未提供且卡面是 PlayingCard 時自動產生(例如「紅心 7」) |
FannedCardsSpring
| 欄位 | 型別 | 預設值 | 說明 |
|---|---|---|---|
stiffness | number | 340 | 彈簧剛性,越大越快到位 |
damping | number | 30 | 阻尼,越大越不晃 |
mass | number | — | 質量,越大越沉 |
FannedCardsHandle
| 欄位 | 型別 | 預設值 | 說明 |
|---|---|---|---|
redeal | () => void | — | 重新發牌:非受控時清空已出的牌;受控時呼叫 onPlayedChange([]),由父層清空 |
PlayingCard
| 欄位 | 型別 | 預設值 | 說明 |
|---|---|---|---|
rank | "A" | "2" | … | "10" | "J" | "Q" | "K" | — | 點數 |
suit | "spade" | "heart" | "diamond" | "club" | — | 花色(紅心、方塊為紅色,黑桃、梅花為黑色) |
faceDown | boolean | false | 蓋牌,顯示菱格紋背面 |
className | string | — | 透傳到卡片根元素 |
另外具名匯出 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 後出現在牌堆