DEV Community

Tarotip
Tarotip

Posted on

Building a Tactile Card Shuffle & Draw Ritual in React with Framer Motion

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)])
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)