Most "draw a card" UIs are a button and a result. That works, but it skips the part people actually enjoy: the little ritual of shuffling, choosing and turning a card over.

I wanted that ritual on the web without a canvas engine or a pile of CSS keyframes. This post walks through the pieces, using React, Framer Motion and Tailwind: a phase state machine, a riffle shuffle, a shared-layout "fly to slot" draw, a 3D flip, and the accessibility details that keep it usable. The snippets are simplified from a real component, but they're meant to be copy-paste friendly.

1. One phase variable drives everything

The whole flow is four phases. Each renders only its own UI, and the caption changes with it:

type Phase = 'shuffle' | 'pick' | 'resolving' | 'reveal' export function DrawRitual({ count, cards }: { count: number; cards: Card[] }) { const [phase, setPhase] = useState<Phase>('shuffle') const [order] = useState(() => shuffle(cards.map((_, i) => i))) const [picked, setPicked] = useState<number[]>([]) const [flipped, setFlipped] = useState<Set<number>>(() => new Set()) const caption = phase === 'shuffle' ? 'Tap the deck to cut' : phase === 'pick' ? `Choose ${count - picked.length} more` : phase === 'resolving' ? 'Almost there…' : 'Turn each card over' return ( <div className="fixed inset-0 flex flex-col bg-neutral-950 text-neutral-100"> <p role="status">{caption}</p> {phase === 'shuffle' && <ShuffleDeck onCut={() => setPhase('pick')} />} {phase === 'pick' && <PickFan /* ... */ />} {phase === 'reveal' && <RevealRow /* ... */ />} </div> ) } 

A union type beats a handful of booleans. You can't be "picking" and "revealing" at once, and TypeScript tells you when you forget a case. resolving is a short beat between the last pick and the reveal: the UI is locked, which stops double taps.

2. The riffle shuffle: ten layers, one stagger

A single card sliding left and right doesn't look like shuffling. A thick deck splitting into two halves and riffling back together does. So I stack ten card backs. Odd layers swing right, even layers swing left, and each layer starts a little later than the one below:

const LAYERS = 10 function ShuffleDeck({ onCut }: { onCut: () => void }) { const reduce = useReducedMotion() return ( <button onClick={onCut} aria-label="Cut the deck" className="absolute left-1/2 top-1/2 h-40 w-24 -translate-x-1/2 -translate-y-1/2" > {Array.from({ length: LAYERS }, (_, layer) => { const side = layer % 2 ? 1 : -1 const lift = -layer * 1.6 // fake thickness return ( <motion.span key={layer} className="absolute inset-0" style={{ zIndex: layer }} initial={{ y: lift }} animate={ reduce ? { y: lift } : { x: [0, side * 52, side * 52, 0, 0], y: [lift, lift - 6, lift - 6, lift, lift], rotate: [0, side * 9, side * 9, 0, 0], } } transition={{ duration: 1.9, times: [0, 0.3, 0.45, 0.8, 1], delay: layer * 0.035, ease: 'easeInOut', repeat: Infinity, }} > <CardBack /> </motion.span> ) })} </button> ) } 

The times array is the trick. The halves part quickly (0 to 0.3), hold for a beat (0.3 to 0.45), snap back together (0.45 to 0.8), then rest until the loop restarts. The delay: layer * 0.035 offset makes the cards land one after another, like a real riffle. Tapping the deck is the "cut", and it moves you to the next phase.

3. The shuffle is theatre; the order comes from Fisher–Yates

The animation is just a performance. The actual order is decided by a plain Fisher–Yates shuffle, with the random source injectable so tests can make it deterministic:

export type RandomSource = () => number export function shuffle<T>( values: readonly T[], random: RandomSource = Math.random ): T[] { const result = [...values] for (let i = result.length - 1; i > 0; i -= 1) { const j = Math.floor(random() * (i + 1)) ;[result[i], result[j]] = [result[j], result[i]] } return result } 

Don't use array.sort(() => Math.random() - 0.5). It looks fine but produces a biased order. Passing a seeded random in tests also means you can snapshot a "random" draw.

The "pick the rest for me" button reuses the same function:

const pickRest = () => { const remaining = order.filter((i) => !picked.includes(i)) setPicked([...picked, ...shuffle(remaining).slice(0, count - picked.length)]) } 

4. Flying from the fan to a slot with layoutId

This is the part I'm happiest with, and it's almost no code. Every card in the fan and every empty slot above it share a layoutId. When a card leaves the fan and appears in a slot, Framer Motion measures both positions and animates between them. No coordinates to compute.

<LayoutGroup> {/* Slots */} <div className="flex justify-center gap-2"> {Array.from({ length: count }, (_, slot) => ( <div key={slot} className="relative aspect-[5/8] w-12 rounded border border-dashed"> {picked[slot] !== undefined && ( <motion.div layoutId={`card-${picked[slot]}`} transition={{ type: 'spring', stiffness: 260, damping: 26 }} className="absolute inset-0" > <CardBack /> </motion.div> )} </div> ))} </div> {/* Fan */} {order.map((cardIndex) => picked.includes(cardIndex) ? null : ( <motion.button key={cardIndex} layoutId={`card-${cardIndex}`} aria-label={`Pick card ${cardIndex + 1}`} onClick={() => pick(cardIndex)} whileHover={{ y: -14 }} whileTap={{ y: -18, scale: 1.04 }} className="focus-visible:ring-4 focus-visible:outline-none" > <CardBack /> </motion.button> ) )} </LayoutGroup> 

Two small details make it feel right. First, a picked card collapses to zero width in the fan, so its neighbours slide together and the next card ends up under your finger. Second, fast tapping can fire two clicks before React re-renders, so I guard pick with a ref, which updates synchronously:

const pickedRef = useRef<number[]>([]) const pick = (cardIndex: number) => { const current = pickedRef.current if (phase !== 'pick' || current.includes(cardIndex) || current.length >= count) return const next = [...current, cardIndex] pickedRef.current = next setPicked(next) navigator.vibrate?.(12) // optional haptic tick if (next.length === count) setTimeout(() => setPhase('resolving'), 700) } 

The 700 ms delay lets the last card finish its flight before the UI moves on.

5. A 3D flip, and reversed cards for free

A flip is two faces in a preserve-3d container. The back face is pre-rotated 180°, and both faces hide their backs with backfaceVisibility:

function FlippableCard({ card, flipped, reversed = false }: Props) { return ( <div style={{ perspective: 1000 }} className="aspect-[5/8] w-full"> <motion.div className="relative h-full w-full" style={{ transformStyle: 'preserve-3d' }} animate={{ rotateY: flipped ? 180 : 0 }} transition={{ duration: 0.7, ease: [0.2, 0.8, 0.3, 1] }} > <div className="absolute inset-0" style={{ backfaceVisibility: 'hidden' }}> <CardBack /> </div> <div className="absolute inset-0" style={{ backfaceVisibility: 'hidden', transform: 'rotateY(180deg)' }} > <img src={card.image} alt={card.name} className={reversed ? 'rotate-180' : undefined} /> </div> </motion.div> </div> ) } 

A reversed (upside-down) card is just a boolean on the data plus rotate-180 on the image. Track flipped cards in a Set and show the "Continue" button only when flipped.size >= count. A "turn all" link is a nice escape hatch for impatient people.

6. Accessibility and reduced motion

A card ritual is exactly the kind of thing that can lock people out, so it's worth a few extra lines:

  • useReducedMotion() turns the riffle into a static stack (see the reduce ? { y: lift } line above). The flow still works, it just doesn't move.
  • motion-reduce:animate-none stops the pulsing hint text for people who ask for less motion.
  • role="status" on the hint paragraph makes screen readers announce each phase change.
  • Real <button> elements with aria-labels ("Pick card 7", then "Position 2: The Hermit" once flipped), plus a visible focus-visible ring, make the whole thing keyboard-playable.
  • aria-hidden="true" on decorative icons keeps the announcements clean.
<p role="status" className={cn('text-sm', phase === 'shuffle' && 'animate-pulse motion-reduce:animate-none')} > {hint} </p> 

Wrapping up

The recipe, in three lines:

  1. One phase union controls what renders, so the flow stays predictable.
  2. layoutId + LayoutGroup gives you fly-between-containers animation for free, and a ref guard keeps rapid taps honest.
  3. useReducedMotion, role="status" and real buttons make it inclusive without much extra code.

If you've built your own card or deck animation, I'd love to hear how you handled shuffling. Riffle layers, canvas, something else?

I build tarot tools at tarotip.com.