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 instead of 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 visible at all) follows from 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 is the same 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 while decelerating still commits, with no
minimum speed required.
The exit continues the gesture instead of restarting 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 continues 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 an aborted swipe also keeps the hand's momentum.
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 same beginLeave a completed drag
would, with a hardcoded velocity (±1200) standing in for a fast flick.
A keyboard user gets the same momentum-driven exit spring a mouse or touch
gesture would produce, instead of an 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 carries the throw's momentum into the exit.
Drag the front card, or use the buttons or arrow keys below.
- Front card
- rank 0, the only draggable card
- 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 draggable card
- 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 instead of 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 visible at all) follows from 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 is the same 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 while decelerating still commits, with no
minimum speed required.
The exit continues the gesture instead of restarting 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 continues 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 an aborted swipe also keeps the hand's momentum.
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 same beginLeave a completed drag
would, with a hardcoded velocity (±1200) standing in for a fast flick.
A keyboard user gets the same momentum-driven exit spring a mouse or touch
gesture would produce, instead of an 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 carries the throw's momentum into the exit.
Drag the front card, or use the buttons or arrow keys below.
Motion Score
scaleScale spring / pressrotateRotationopacityFade / cross-fade