Interaction Choreography

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.en

New project or installation trouble? Start here →

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

7
58
340
30
Active edited usageDefault props
<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.json

Or, 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

PropTypeDefaultDescription
itemsFannedCardItem[]—The hand, fanned left to right in array order
playedIdsstring[]—Controlled played ids (in play order); managed internally when omitted
defaultPlayedIdsstring[][]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
spacingnumber58Base horizontal spacing between cards (px); 10px tighter once the hand holds more than 5 cards, never below 26
springConfigFannedCardsSpring{ stiffness: 340, damping: 30 }Spring parameters for fan re-layout and the play flight
cardWidthnumber112Card width (px)
cardHeightnumber160Card height (px)
disabledbooleanfalseDisables lifting, dragging and keyboard play
listLabelstring"Hand"Accessible name of the hand (listbox); the Chinese build defaults to "手牌" (hand)
refReact.Ref<FannedCardsHandle>—Access redeal() and other imperative actions
classNamestring—Passed through to the outermost container

FannedCardItem

FieldTypeDefaultDescription
idstring—Unique id; played state is tracked by it
nodeReact.ReactNode—Card face content — any node
labelstring—Accessible name; generated automatically when omitted and the face is a PlayingCard (e.g. "7 of hearts")

FannedCardsSpring

FieldTypeDefaultDescription
stiffnessnumber340Spring stiffness — higher settles faster
dampingnumber30Damping — higher wobbles less
massnumber—Mass — higher feels heavier

FannedCardsHandle

FieldTypeDefaultDescription
redeal() => void—Redeal: clears the played cards when uncontrolled; when controlled it calls onPlayedChange([]) and the parent clears them

PlayingCard

FieldTypeDefaultDescription
rank"A" | "2" | … | "10" | "J" | "Q" | "K"—Rank
suit"spade" | "heart" | "diamond" | "club"—Suit (hearts and diamonds are red, spades and clubs black)
faceDownbooleanfalseFace down, showing the diamond-lattice back
classNamestring—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 PlayingCard suit 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 is role="option" with aria-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-live region, e.g. "Played: 7 of hearts"; the name comes from label, or is generated automatically for a PlayingCard face
  • 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