Playing Cards
A hand that opens under the pointer. Slide a card to reorder it, flick it up to play.
A hand that opens under the pointer. Slide a card to reorder it, flick it up to play.
experiments/playing-cards/index.tsx
'use client'
import { useEffect, useRef, useState } from 'react'
type Suit = 'spades' | 'hearts' | 'diamonds' | 'clubs'
type Rank = (typeof RANKS)[number]
type Card = { id: string; rank: Rank; suit: Suit }
type Placement = { x: number; y: number; rot: number }
const RANKS = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K'] as const
const SUITS: Suit[] = ['spades', 'hearts', 'diamonds', 'clubs']
const CARD_W = 86
const CARD_H = Math.round(CARD_W * 1.4)
const STAGE_W = 430
const STAGE_H = 320
/**
* Where the hand's middle card sits, measured from the top of the stage. The
* slack below it is for the arc: the ends of a seven-card fan dip ~27px, and
* without room for that the outer cards clip the controls.
*/
const HAND_Y = STAGE_H - CARD_H - 30
const PILE_Y = 4
/**
* Pitch between cards, widened for a small hand so three cards do not sit in the
* same cramped stack seven need. Capped so the fan's width stays constant from
* five cards up — a hand that grew sideways with every card would outrun the canvas.
*/
function stepX(count: number): number {
return Math.min(66, 264 / Math.max(count - 1, 1))
}
const STEP_DEG = 6.5
/** Per squared slot-offset, so the ends of the fan dip like a real hand. */
const ARC_RISE = 3
/**
* Two falloffs, not one. A thumb sliding across a hand pushes its neighbours
* aside — so `OPEN_FALLOFF` is wide enough to part several cards — but it does
* not raise them, so the lift and the straightening use a falloff barely wider
* than one card. Sharing a single falloff makes the whole hand levitate.
*/
const OPEN_FALLOFF = 54
const NEAR_FALLOFF = 21
const SPREAD = 17
const LIFT = 26
/** Past this the card has left the hand, so sliding no longer reorders it. */
const OUT_OF_HAND = 26
const PLAY_PX = 62
const PLAY_VELOCITY = 0.5
/** Pointer travel above which a pointerup is a gesture, not a click. */
const CLICK_SLOP = 6
const HAND_SIZES: Record<string, number> = { '3 cards': 3, '5 cards': 5, '7 cards': 7 }
const INK = '#1b1b1f'
const RED = '#c0342e'
const FACE = '#fbfaf6'
export default function PlayingCards({ variant = '5 cards' }: { variant?: string }) {
const size = HAND_SIZES[variant] ?? 5
const [seed, setSeed] = useState(1)
const [hand, setHand] = useState<Card[]>(() => deal(1, size))
const [played, setPlayed] = useState<Array<{ card: Card; from: Placement; rest: Placement }>>([])
const [announcement, setAnnouncement] = useState('')
const [pointerX, setPointerX] = useState<number | null>(null)
const [focusedId, setFocusedId] = useState<string | null>(null)
const [drag, setDrag] = useState<(Placement & { id: string; armed: boolean }) | null>(null)
const stage = useRef<HTMLDivElement>(null)
const buttons = useRef(new Map<string, HTMLButtonElement>())
/** Survives the pointerup that clears `grab`, because click fires after it. */
const travelled = useRef(0)
const grab = useRef<{
pointerId: number
id: string
offsetX: number
offsetY: number
startX: number
startY: number
x: number
y: number
t: number
vx: number
vy: number
travel: number
} | null>(null)
useEffect(() => {
setHand(deal(seed, size))
setPlayed([])
setAnnouncement('')
}, [seed, size])
// The stage is CSS-scaled down on narrow viewports, so getBoundingClientRect
// reports scaled pixels while every constant here is unscaled. Dividing by this
// is what keeps a dragged card under the pointer at any width.
function stagePoint(event: { clientX: number; clientY: number }) {
const rect = stage.current?.getBoundingClientRect()
if (!rect) return { x: 0, y: 0 }
const scale = rect.width / STAGE_W
return {
x: (event.clientX - rect.left) / scale - STAGE_W / 2,
y: (event.clientY - rect.top) / scale,
}
}
const focusedIndex = hand.findIndex((card) => card.id === focusedId)
// One code path for pointer and keyboard: a focused card attracts the spread
// exactly as a hovered one does.
const attractor =
drag !== null
? null
: (pointerX ?? (focusedIndex === -1 ? null : slot(focusedIndex, hand.length).x))
const views = hand.map((card, index) => {
const base = slot(index, hand.length)
if (drag?.id === card.id) return { card, ...drag, z: 60, held: true }
if (attractor === null) return { card, ...base, z: index, held: false }
const distance = base.x - attractor
const open = Math.exp(-((distance / OPEN_FALLOFF) ** 2))
const near = Math.exp(-((distance / NEAR_FALLOFF) ** 2))
return {
card,
x: base.x + Math.tanh(distance / 18) * SPREAD * open,
y: base.y - LIFT * near,
rot: base.rot * (1 - 0.45 * near),
z: Math.round(20 + near * 20),
held: false,
}
})
const viewsRef = useRef(views)
viewsRef.current = views
function play(id: string, from: Placement) {
const index = hand.findIndex((entry) => entry.id === id)
const card = hand[index]
if (!card) return
setHand((current) => current.filter((entry) => entry.id !== id))
setPlayed((current) => [...current, { card, from, rest: restingPlace(card.id) }])
setAnnouncement(
hand.length === 1 ? `${label(card)} played. Hand empty.` : `${label(card)} played.`,
)
const next = hand[index + 1] ?? hand[index - 1]
if (next && focusedId === id) {
requestAnimationFrame(() => buttons.current.get(next.id)?.focus())
}
}
function moveTo(id: string, target: number) {
setHand((current) => {
const from = current.findIndex((card) => card.id === id)
const to = Math.max(0, Math.min(current.length - 1, target))
if (from === -1 || from === to) return current
const next = [...current]
next.splice(to, 0, ...next.splice(from, 1))
return next
})
}
function onPointerDown(event: React.PointerEvent<HTMLButtonElement>, index: number) {
const view = viewsRef.current[index]
if (!event.isPrimary || !view) return
const point = stagePoint(event)
travelled.current = 0
event.currentTarget.setPointerCapture(event.pointerId)
grab.current = {
pointerId: event.pointerId,
id: view.card.id,
offsetX: view.x - point.x,
offsetY: view.y - point.y,
startX: point.x,
startY: point.y,
x: point.x,
y: point.y,
t: performance.now(),
vx: 0,
vy: 0,
travel: 0,
}
setDrag({ id: view.card.id, x: view.x, y: view.y, rot: view.rot, armed: false })
}
function onPointerMove(event: React.PointerEvent<HTMLButtonElement>) {
const held = grab.current
if (!held || event.pointerId !== held.pointerId) return
const point = stagePoint(event)
const now = performance.now()
const elapsed = Math.max(now - held.t, 1)
held.vx = (point.x - held.x) / elapsed
held.vy = (point.y - held.y) / elapsed
held.travel = Math.max(
held.travel,
Math.abs(point.x - held.startX),
Math.abs(point.y - held.startY),
)
held.x = point.x
held.y = point.y
held.t = now
const x = point.x + held.offsetX
const y = point.y + held.offsetY
setDrag({
id: held.id,
x,
y,
// Tilt from horizontal speed, so a fast slide leans into the direction.
rot: Math.max(-18, Math.min(18, held.vx * 26)),
armed: y < HAND_Y - PLAY_PX,
})
if (y > HAND_Y - OUT_OF_HAND) {
moveTo(held.id, Math.round(x / stepX(hand.length) + (hand.length - 1) / 2))
}
}
function onPointerUp(event: React.PointerEvent<HTMLButtonElement>) {
const held = grab.current
if (!held || event.pointerId !== held.pointerId) return
grab.current = null
travelled.current = held.travel
setDrag(null)
const y = held.y + held.offsetY
const flung = y < HAND_Y - PLAY_PX || held.vy < -PLAY_VELOCITY
if (flung) {
play(held.id, {
x: held.x + held.offsetX,
y,
rot: Math.max(-18, Math.min(18, held.vx * 26)),
})
}
}
function onKeyDown(event: React.KeyboardEvent<HTMLButtonElement>, index: number) {
const view = viewsRef.current[index]
if (!view) return
const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0
if (step !== 0) {
event.preventDefault()
if (event.altKey) {
moveTo(view.card.id, index + step)
return
}
const neighbour = hand[index + step]
if (neighbour) buttons.current.get(neighbour.id)?.focus()
return
}
if (event.key === 'ArrowUp' || event.key === 'Enter' || event.key === ' ') {
// preventDefault keeps Enter and Space from also firing click, which would
// route a keyboard activation through the drag-vs-click check below.
event.preventDefault()
play(view.card.id, { x: view.x, y: view.y, rot: view.rot })
}
}
const armed = drag?.armed ?? false
return (
<div className="flex flex-col items-center gap-5">
<style>{STYLES}</style>
{/* The wrapper carries the scaled height, because a transform leaves the
layout box at full size and would strand 100px of dead space below. */}
<div className="h-[218px] sm:h-[288px] lg:h-[320px]">
<div
ref={stage}
className="relative origin-top scale-[0.68] sm:scale-90 lg:scale-100"
style={{ width: STAGE_W, height: STAGE_H }}
onPointerMove={(event) => {
if (!grab.current) setPointerX(stagePoint(event).x)
}}
onPointerLeave={() => setPointerX(null)}
>
{/* An ivory card on the light canvas is nearly the same value as the
canvas, so the fan needs a surface to sit on. --hairline reads as a
lift against near-black and as a shadow against white. */}
<div
aria-hidden="true"
className="pointer-events-none absolute left-1/2 -translate-x-1/2 rounded-[50%] transition-opacity duration-500"
style={{
top: HAND_Y + 50,
width: 400,
height: 100,
background: 'radial-gradient(closest-side, var(--hairline), transparent)',
// Nothing left to ground once the hand is played out, and on its own
// it reads as a stray grey blob.
opacity: hand.length === 0 ? 0 : 1,
}}
/>
<div
className={`pointer-events-none absolute left-1/2 flex items-center justify-center rounded-lg border border-dashed transition-colors duration-200 ${
armed ? 'border-accent text-accent' : 'border-hairline text-dim'
}`}
style={{
top: PILE_Y,
width: CARD_W,
height: CARD_H,
transform: 'translateX(-50%)',
}}
>
{/* Once a card has landed here it covers this label, so there is no
point rendering one underneath it. */}
<span className="text-[11px] font-medium tracking-tight">
{played.length > 0 ? '' : armed ? 'Release' : 'Play a card'}
</span>
</div>
{played.map((entry, index) => (
<PlayedCard key={entry.card.id} {...entry} z={index} />
))}
{views.map((view, index) => (
<button
key={view.card.id}
ref={(node) => {
if (node) buttons.current.set(view.card.id, node)
else buttons.current.delete(view.card.id)
}}
type="button"
data-pc-card
aria-label={`${label(view.card)}, ${index + 1} of ${hand.length}`}
className="absolute left-1/2 cursor-grab rounded-[6px] outline-none focus-visible:ring-2 focus-visible:ring-accent active:cursor-grabbing"
style={{
top: 0,
width: CARD_W,
height: CARD_H,
zIndex: view.z,
touchAction: 'none',
transformOrigin: '50% 90%',
transform: `translate(calc(-50% + ${view.x}px), ${view.y}px) rotate(${view.rot}deg)`,
transition: view.held ? 'none' : 'transform 320ms cubic-bezier(0.2, 0.8, 0.2, 1)',
boxShadow: view.held
? '0 18px 30px -10px rgba(0,0,0,0.5)'
: '0 6px 14px -6px rgba(0,0,0,0.45)',
}}
onPointerDown={(event) => onPointerDown(event, index)}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={() => {
grab.current = null
setDrag(null)
}}
onFocus={() => setFocusedId(view.card.id)}
onBlur={() => setFocusedId(null)}
onKeyDown={(event) => onKeyDown(event, index)}
onClick={() => {
// Every drag ends in a click on the same element. Only a pointerup
// that never really moved is one.
if (travelled.current > CLICK_SLOP) return
play(view.card.id, { x: view.x, y: view.y, rot: view.rot })
}}
>
<CardFace card={view.card} />
</button>
))}
</div>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setSeed((current) => current + 1)}
className="rounded-full border border-hairline px-3 py-1.5 text-xs font-medium text-dim transition-colors hover:text-ink"
>
Deal
</button>
<p className="text-xs text-dim">
{hand.length === 0 ? (
'Hand played out.'
) : (
<>
Slide to reorder · flick up to play · <kbd className="font-mono">↑</kbd> plays
</>
)}
</p>
</div>
<p aria-live="polite" className="sr-only">
{announcement}
</p>
</div>
)
}
function PlayedCard({
card,
from,
rest,
z,
}: {
card: Card
from: Placement
rest: Placement
z: number
}) {
const [landed, setLanded] = useState(false)
useEffect(() => {
const frame = requestAnimationFrame(() => setLanded(true))
return () => cancelAnimationFrame(frame)
}, [])
const at = landed ? { ...rest, y: PILE_Y + rest.y } : from
return (
<div
data-pc-card
className="pointer-events-none absolute left-1/2 rounded-[6px]"
style={{
top: 0,
width: CARD_W,
height: CARD_H,
zIndex: 10 + z,
transform: `translate(calc(-50% + ${at.x}px), ${at.y}px) rotate(${at.rot}deg)`,
transition: 'transform 460ms cubic-bezier(0.16, 1, 0.3, 1)',
boxShadow: '0 8px 18px -8px rgba(0,0,0,0.5)',
}}
>
<CardFace card={card} />
</div>
)
}
/* ---------------------------------------------------------------- geometry */
function slot(index: number, count: number): Placement {
const offset = index - (count - 1) / 2
return {
x: offset * stepX(count),
y: HAND_Y + offset * offset * ARC_RISE,
rot: offset * STEP_DEG,
}
}
/**
* Derived from the card's identity rather than drawn fresh, so the same deal
* always lands the same way and a re-render never nudges a settled pile.
*/
function restingPlace(id: string): Placement {
const random = mulberry32(hash(id))
return { x: (random() - 0.5) * 14, y: random() * 7, rot: (random() - 0.5) * 15 }
}
function deal(seed: number, count: number): Card[] {
const deck: Card[] = SUITS.flatMap((suit) =>
RANKS.map((rank) => ({ id: `${rank}${suit[0]}`, rank, suit })),
)
const random = mulberry32(seed * 2654435761)
for (let i = deck.length - 1; i > 0; i -= 1) {
const j = Math.floor(random() * (i + 1))
const swap = deck[i] as Card
deck[i] = deck[j] as Card
deck[j] = swap
}
return deck.slice(0, count)
}
function mulberry32(seed: number) {
let state = seed >>> 0
return () => {
state = (state + 0x6d2b79f5) >>> 0
let t = state
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
function hash(value: string): number {
let out = 2166136261
for (let i = 0; i < value.length; i += 1) {
out = Math.imul(out ^ value.charCodeAt(i), 16777619)
}
return out >>> 0
}
function label(card: Card): string {
const names: Record<string, string> = { A: 'Ace', J: 'Jack', Q: 'Queen', K: 'King' }
return `${names[card.rank] ?? card.rank} of ${card.suit}`
}
/* -------------------------------------------------------------------- face */
const SUIT_PATHS: Record<Suit, string> = {
spades:
'M12 1.6C12 1.6 3.4 8.6 3.4 13.6a4.5 4.5 0 0 0 7.2 3.6c-.2 2.1-.9 3.4-2.4 4.8h7.6c-1.5-1.4-2.2-2.7-2.4-4.8a4.5 4.5 0 0 0 7.2-3.6c0-5-8.6-12-8.6-12Z',
hearts:
'M12 21.6C6.2 15.9 2.7 12.4 2.7 8.6A4.9 4.9 0 0 1 7.6 3.7c1.9 0 3.5.9 4.4 2.4a5.4 5.4 0 0 1 4.4-2.4 4.9 4.9 0 0 1 4.9 4.9c0 3.8-3.5 7.3-9.3 13Z',
diamonds: 'M12 1.4 20.2 12 12 22.6 3.8 12Z',
clubs:
'M12 1.8a4.2 4.2 0 0 0-3.3 6.8 4.2 4.2 0 1 0 2.5 7.6c-.2 2.2-.9 3.6-2.4 5.2h6.4c-1.5-1.6-2.2-3-2.4-5.2a4.2 4.2 0 1 0 2.5-7.6A4.2 4.2 0 0 0 12 1.8Z',
}
/** Column centres and the rows each rank fills, in the 100×140 face box. */
const COLS: readonly [number, number, number] = [32, 50, 68]
const PIP_TOP = 32
const PIP_BOTTOM = 108
/** `[column, row]`, the row a fraction of the pip field top to bottom. */
const PIP_LAYOUTS: Partial<Record<Rank, Array<[0 | 1 | 2, number]>>> = {
'2': [
[1, 0],
[1, 1],
],
'3': [
[1, 0],
[1, 0.5],
[1, 1],
],
'4': [
[0, 0],
[2, 0],
[0, 1],
[2, 1],
],
'5': [
[0, 0],
[2, 0],
[1, 0.5],
[0, 1],
[2, 1],
],
'6': [
[0, 0],
[2, 0],
[0, 0.5],
[2, 0.5],
[0, 1],
[2, 1],
],
'7': [
[0, 0],
[2, 0],
[1, 0.25],
[0, 0.5],
[2, 0.5],
[0, 1],
[2, 1],
],
'8': [
[0, 0],
[2, 0],
[1, 0.25],
[0, 0.5],
[2, 0.5],
[1, 0.75],
[0, 1],
[2, 1],
],
'9': [
[0, 0],
[2, 0],
[0, 1 / 3],
[2, 1 / 3],
[1, 0.5],
[0, 2 / 3],
[2, 2 / 3],
[0, 1],
[2, 1],
],
'10': [
[0, 0],
[2, 0],
[1, 1 / 6],
[0, 1 / 3],
[2, 1 / 3],
[0, 2 / 3],
[2, 2 / 3],
[1, 5 / 6],
[0, 1],
[2, 1],
],
}
const COURTS: Rank[] = ['J', 'Q', 'K']
function CardFace({ card }: { card: Card }) {
const ink = card.suit === 'hearts' || card.suit === 'diamonds' ? RED : INK
const path = SUIT_PATHS[card.suit]
const pips = PIP_LAYOUTS[card.rank]
return (
<svg
viewBox="0 0 100 140"
width="100%"
height="100%"
aria-hidden="true"
className="block rounded-[6px]"
style={{ background: FACE }}
>
<rect x="0.5" y="0.5" width="99" height="139" rx="6" fill="none" stroke="rgba(0,0,0,0.14)" />
{[0, 180].map((turn) => (
<g key={turn} transform={`rotate(${turn} 50 70)`} fill={ink}>
<text
x="11.5"
y="21"
textAnchor="middle"
fontSize={card.rank === '10' ? 13 : 15}
fontWeight="600"
letterSpacing="-0.04em"
fontFamily="var(--font-sans), system-ui, sans-serif"
>
{card.rank}
</text>
<Suit path={path} x={11.5} y={28.5} size={9} />
</g>
))}
{card.rank === 'A' ? <Suit path={path} x={50} y={70} size={38} fill={ink} /> : null}
{pips?.map(([col, row]) => {
const y = PIP_TOP + row * (PIP_BOTTOM - PIP_TOP)
return (
<Suit
key={`${col}-${row}`}
path={path}
x={COLS[col]}
y={y}
size={14}
fill={ink}
flip={row > 0.5}
/>
)
})}
{COURTS.includes(card.rank) ? <Court rank={card.rank} path={path} ink={ink} /> : null}
</svg>
)
}
/**
* Half the card is drawn, then repeated rotated 180° about the centre — the
* structure of a real court card, with a monogram standing in for the figure.
*/
function Court({ rank, path, ink }: { rank: Rank; path: string; ink: string }) {
return (
<>
<rect
x="26"
y="26"
width="48"
height="88"
rx="4"
fill="none"
stroke={ink}
strokeOpacity="0.22"
/>
<line x1="26" y1="70" x2="74" y2="70" stroke={ink} strokeOpacity="0.22" />
{[0, 180].map((turn) => (
<g key={turn} transform={`rotate(${turn} 50 70)`}>
<Suit path={path} x={50} y={41} size={13} fill={ink} />
<text
x="50"
y="65"
textAnchor="middle"
fontSize="27"
fill={ink}
fontFamily="Georgia, 'Times New Roman', serif"
>
{rank}
</text>
</g>
))}
</>
)
}
function Suit({
path,
x,
y,
size,
fill,
flip,
}: {
path: string
x: number
y: number
size: number
fill?: string
flip?: boolean
}) {
const scale = size / 24
return (
<path
d={path}
fill={fill}
transform={`translate(${x} ${y}) rotate(${flip ? 180 : 0}) scale(${scale}) translate(-12 -12)`}
/>
)
}
const STYLES = `
@media (prefers-reduced-motion: reduce) {
[data-pc-card] { transition-duration: 1ms !important; }
}
`