Fanned Cards
A hand of cards fanned out like a held deck: hover lifts a card while neighbours make room, and dragging up or flicking plays it onto a pile. Card faces accept any content.
Lays a set of cards out like a hand you are holding: the hovered (or keyboard-selected) card lifts while its neighbours slide outward to make room; drag a card up or flick it and it flies to the pile above the component, landing askew on top of the cards played before it, while the rest of the hand re-fans with the same spring. Card faces accept any node, and a simple PlayingCard sub-component is included; it also works well as a playful picker where playing a plan card selects it. The played list supports controlled and uncontrolled modes.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add fanned-cards.enPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<FannedCards/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
Installation
npx webberui@latest add https://webberui.com/r/fanned-cards.en.jsonOr, once registries are configured in components.json, install it as @webberui/fanned-cards.
Usage
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(`Played: ${id}`)}
/>
<button type="button" onClick={() => setPlayed([])}>
Redeal
</button>
</>
);
}
// Uncontrolled: clear the pile with ref.redeal()
export function UncontrolledHand() {
const ref = React.useRef<FannedCardsHandle>(null);
return (
<>
<FannedCards ref={ref} items={items} />
<button type="button" onClick={() => ref.current?.redeal()}>
Redeal
</button>
</>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | FannedCardItem[] | — | The hand, fanned left to right in array order |
playedIds | string[] | — | Controlled played ids (in play order); managed internally when omitted |
defaultPlayedIds | string[] | [] | Initial played ids in uncontrolled mode |
onPlayedChange | (ids: string[]) => void | — | Called when the played list changes (a play or a redeal) |
onPlay | (id: string) => void | — | Called when a card is played |
spacing | number | 58 | Base horizontal spacing between cards (px); 10px tighter once the hand holds more than 5 cards, never below 26 |
springConfig | FannedCardsSpring | { stiffness: 340, damping: 30 } | Spring parameters for fan re-layout and the play flight |
cardWidth | number | 112 | Card width (px) |
cardHeight | number | 160 | Card height (px) |
disabled | boolean | false | Disables lifting, dragging and keyboard play |
listLabel | string | "Hand" | Accessible name of the hand (listbox); the Chinese build defaults to "手牌" (hand) |
ref | React.Ref<FannedCardsHandle> | — | Access redeal() and other imperative actions |
className | string | — | Passed through to the outermost container |
FannedCardItem
| Field | Type | Default | Description |
|---|---|---|---|
id | string | — | Unique id; played state is tracked by it |
node | React.ReactNode | — | Card face content — any node |
label | string | — | Accessible name; generated automatically when omitted and the face is a PlayingCard (e.g. "7 of hearts") |
FannedCardsSpring
| Field | Type | Default | Description |
|---|---|---|---|
stiffness | number | 340 | Spring stiffness — higher settles faster |
damping | number | 30 | Damping — higher wobbles less |
mass | number | — | Mass — higher feels heavier |
FannedCardsHandle
| Field | Type | Default | Description |
|---|---|---|---|
redeal | () => void | — | Redeal: clears the played cards when uncontrolled; when controlled it calls onPlayedChange([]) and the parent clears them |
PlayingCard
| Field | Type | Default | Description |
|---|---|---|---|
rank | "A" | "2" | … | "10" | "J" | "Q" | "K" | — | Rank |
suit | "spade" | "heart" | "diamond" | "club" | — | Suit (hearts and diamonds are red, spades and clubs black) |
faceDown | boolean | false | Face down, showing the diamond-lattice back |
className | string | — | Passed through to the card root element |
describePlayingCard(rank, suit) is also exported and returns a spoken name such as "7 of hearts".
How it works
- Fan formula: with n cards left in hand, card i has offset i − (n − 1) / 2, an angle step of min(9°, 46° / (n − 1)), a dip of |angle| × 1.9 and a horizontal shift of offset × spacing; the rotation origin is the bottom centre of the card.
- The lifted card rises 54px, its rotation shrinks to 30% of the original and it moves to the top layer, while each neighbour slides outward by 0.35 × spacing.
- A card is played when dragged up more than 80px or flicked upward faster than 600px/s. Dragging only goes up; releasing without playing snaps the card back.
- The pile sits 1.4 × the card height above the hand (224px by default); each card's scatter angle (±13°) and offset are generated at the moment it is played and kept, and ids pushed in directly by a controlled parent get a stable angle from a hash of the id.
- Spring parameters are exposed through
springConfig; the card-back colour can be overridden with--wb-fanned-cards-back. - The
PlayingCardsuit shapes are hand-drawn SVG, and J / Q / K show only a large letter and suit — no court art.
Accessibility
- The hand is
role="listbox"and each card isrole="option"witharia-selected, using a roving tabindex so Tab stops only on the selected card - ← / → move the selection (applying the lift), Home / End jump to either end, and Enter or Space plays the card; focus then moves to the neighbouring card, and Enter is guarded against IME composition
- Plays and redeals are announced in an
aria-liveregion, e.g. "Played: 7 of hearts"; the name comes fromlabel, or is generated automatically for aPlayingCardface - Cards already on the pile are hidden from assistive technology so they cannot be selected by mistake
- When the system's reduce motion setting is on, the fan snaps into place without spring transitions, and a played card does not fly — it fades out in place for 150ms and then appears on the pile
Mouse Parallax Scene
Multiple layers shift with the mouse to create depth, with a gyroscope fallback.
Flashcard Deck
A study flashcard deck React component. Tap or press Space to flip in 3D, drag or use arrow keys to sort into known and unknown, with a progress bar and an end screen showing an accuracy ring, restart and review-unknown options.