MynduiMyndui
0

Anatomy of the Swipe Deck

A card stack built from a rank formula instead of absolute layout, a drag gesture that maps distance straight to rotation, and a release that keeps the throw's own momentum through the exit spring.

01/03A stack is a formula, not a deck of positioned cards
Front card
rank 0 — the only one that's draggable
Behind ranks
scale 1 − rank×0.05, y rank×16 · spring 320/32/0.9
tsx
const BehindCard: React.FC<{ rank: number; children: React.ReactNode }> = ({ rank, children }) => (<motion.div  style={{ zIndex: 30 - rank, transformOrigin: "top center" }}  animate={{    scale: 1 - rank * 0.05,    y: rank * 16,    opacity: rank > 2 ? 0 : 1,  }}  transition={STACK_SPRING} // { type: "spring", stiffness: 320, damping: 32, mass: 0.9 }>  {children}</motion.div>);
01

A stack is a formula, not a deck of positioned cards

Swipeable decks are often built by absolutely positioning every card and recomputing offsets on each swipe. SwipeDeck keeps the stack as one formula applied to whatever rank a card currently holds, so "the next card moves up" is a spring on a number, not a layout recalculation.

Only the front card (rank === 0) is a FrontCard with drag handlers; everything behind it is a BehindCard whose entire visual state — scale, vertical offset, whether it's even visible — falls out of its rank. When a swipe commits, order shifts by one and every remaining card's rank drops by one; React re-renders with new rank props, and the same spring animates each card from its old slot to its new one. There's no separate "promote" animation — it's the identical STACK_SPRING the deck already uses for steady state.

02/03Drag maps to rotation, release keeps the throw
Front card
drag → rotate · x maps to ±18°
Behind card
exit spring 60/16 seeded with velocity
tsx
const rotate = useTransform(x, [-320, 0, 320], [-18, 0, 18], { clamp: true });const handleDragEnd = (_e, info: PanInfo) => {const power = info.offset.x + info.velocity.x * 0.2;if (power > threshold || info.velocity.x > 700) {  onDecide("right", info.velocity.x);} else if (power < -threshold || info.velocity.x < -700) {  onDecide("left", info.velocity.x);} else {  animate(x, 0, { type: "spring", stiffness: 520, damping: 32, velocity: info.velocity.x });}};
02

Drag maps to rotation, release keeps the throw

rotate is a live useTransform of the drag's x motion value, so the tilt tracks the finger in real time with zero extra state. On release, power blends distance and velocity into one number — a short, fast flick can commit a swipe even if it never crossed the raw threshold (110px), and a slow drag that overshoots the threshold but is decelerating still counts as a real commit rather than requiring a minimum speed.

The exit continues the gesture, it doesn't restart it. The exit spring's velocity is seeded with the release velocity from the drag, not zero. A fast flick flies off fast; a slow drag past threshold drifts off slowly — the card's exit speed is a direct continuation of how it was moving the instant you let go, which is why { stiffness: 60, damping: 16 } reads as "thrown" rather than "faded out." A snapped-back drag (one that didn't clear the threshold) uses a different, snappier spring (520 / 32) seeded the same way, so even an aborted swipe still feels like it remembers your hand.

tsx
 
const controls = animate(x, sign * (width * 0.9 + 360), {
  type: "spring",
  velocity: leaving.velocity,
  stiffness: 60,
  damping: 16,
  restDelta: 2,
  onComplete: () => onLeftRef.current(),
});

Keyboard parity: arrow keys call the exact same beginLeave a completed drag would, just with a hardcoded velocity (±1200) standing in for a fast flick — so a keyboard user gets the same momentum-driven exit spring a mouse or touch gesture would produce, not a instant cut or a separate fade transition.

tsx
 
const handleKey = (e: React.KeyboardEvent) => {
  if (e.key === "ArrowLeft") beginLeave("left", -1200);
  if (e.key === "ArrowRight") beginLeave("right", 1200);
};
03/03Result
Aria Wells

Aria Wells

Product Designer

Mateo Cruz

Mateo Cruz

Staff Engineer

Noah Kim

Noah Kim

Founder

03

The result

One rank formula drives the whole stack, one useTransform maps drag to rotation, and one seeded spring makes the exit feel like it's still being thrown after you've let go — drag the front card, or use the buttons or arrow keys below.

Motion Score

Swipe DeckSSCompositor-only
SscaleScale spring / press
SrotateRotation
SopacityFade / cross-fade
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →