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.
↓Scroll to step through it
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.
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.
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.
const handleKey = (e: React.KeyboardEvent) => {
if (e.key === "ArrowLeft") beginLeave("left", -1200);
if (e.key === "ArrowRight") beginLeave("right", 1200);
};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.
- 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
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>);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.
- 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
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>);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.
- Front card
- drag → rotate · x maps to ±18°
- Behind card
- exit spring 60/16 seeded with velocity
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 });}};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.
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.
const handleKey = (e: React.KeyboardEvent) => {
if (e.key === "ArrowLeft") beginLeave("left", -1200);
if (e.key === "ArrowRight") beginLeave("right", 1200);
};Aria Wells
Product Designer
Mateo Cruz
Staff Engineer
Noah Kim
Founder
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
scaleScale spring / pressrotateRotationopacityFade / cross-fade