Tinder-Style Card Swipe Animation Implementation in Mobile Apps
Card stack with swipe is an established pattern beyond dating apps. Product selection, content rating, quiz interfaces—the pattern works anywhere quick binary decisions are needed. The technical challenge: cards follow the finger, rotate proportionally to horizontal displacement, reach threshold and fly away; bottom card scales and rises.
Key Animation Parameters
Rotation: angle proportional to drag. Formula: rotation = translationX / screenWidth * 25°. At maximum displacement (~40% screen width), rotation reaches 25 degrees. Feels natural.
Threshold: usually 30–40% screen width or velocity > 800 dp/s. Velocity-based threshold is more important: user can swipe sharply without reaching 40%.
Cards below top: second card scale 0.95, third 0.90. On top card dismiss, second animates to 1.0, third to 0.95. Reverse animation on undo.
iOS: UIPanGestureRecognizer + UISpringTimingParameters
class SwipeCardView: UIView {
private var initialCenter = CGPoint.zero
private let threshold: CGFloat = UIScreen.main.bounds.width * 0.35
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { true }
@objc func handlePan(_ gesture: UIPanGestureRecognizer) {
let translation = gesture.translation(in: superview)
let velocity = gesture.velocity(in: superview)
switch gesture.state {
case .began:
initialCenter = center
case .changed:
center = CGPoint(x: initialCenter.x + translation.x, y: initialCenter.y + translation.y)
let rotation = (translation.x / UIScreen.main.bounds.width) * 0.4 // radians
transform = CGAffineTransform(rotationAngle: rotation)
// Overlay opacity for direction indication
let progress = abs(translation.x) / threshold
likeOverlay.alpha = translation.x > 0 ? min(progress, 1.0) : 0
nopeOverlay.alpha = translation.x < 0 ? min(progress, 1.0) : 0
case .ended, .cancelled:
let shouldDismiss = abs(translation.x) > threshold || abs(velocity.x) > 800
if shouldDismiss {
dismissCard(direction: translation.x > 0 ? .right : .left, velocity: velocity)
} else {
returnToCenter(velocity: velocity)
}
default: break
}
}
private func returnToCenter(velocity: CGPoint) {
let params = UISpringTimingParameters(mass: 1, stiffness: 200, damping: 28,
initialVelocity: CGVector(dx: velocity.x/500, dy: velocity.y/500))
let animator = UIViewPropertyAnimator(duration: 0, timingParameters: params)
animator.addAnimations {
self.center = self.initialCenter
self.transform = .identity
self.likeOverlay.alpha = 0
self.nopeOverlay.alpha = 0
}
animator.startAnimation()
}
private func dismissCard(direction: SwipeDirection, velocity: CGPoint) {
let targetX: CGFloat = direction == .right ? UIScreen.main.bounds.width * 1.5 : -UIScreen.main.bounds.width * 1.5
let params = UISpringTimingParameters(mass: 0.8, stiffness: 150, damping: 20,
initialVelocity: CGVector(dx: velocity.x/300, dy: velocity.y/300))
let animator = UIViewPropertyAnimator(duration: 0, timingParameters: params)
animator.addAnimations {
self.center.x = targetX
self.transform = CGAffineTransform(rotationAngle: direction == .right ? 0.5 : -0.5)
}
animator.addCompletion { _ in
self.removeFromSuperview()
self.onDismiss?(direction)
}
animator.startAnimation()
}
}
Android Compose: Drag + Animate
@Composable
fun SwipeCard(
card: Card,
onSwipeLeft: () -> Unit,
onSwipeRight: () -> Unit,
) {
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
val threshold = screenWidth * 0.35f
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
val rotation by remember { derivedStateOf { (offsetX / with(LocalDensity.current) { screenWidth.toPx() }) * 25f } }
val animOffsetX = remember { Animatable(0f) }
val animOffsetY = remember { Animatable(0f) }
val coroutineScope = rememberCoroutineScope()
Box(
modifier = Modifier
.offset { IntOffset(animOffsetX.value.roundToInt(), animOffsetY.value.roundToInt()) }
.rotate(rotation)
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { },
onDrag = { _, dragAmount ->
coroutineScope.launch {
animOffsetX.snapTo(animOffsetX.value + dragAmount.x)
animOffsetY.snapTo(animOffsetY.value + dragAmount.y)
}
},
onDragEnd = {
coroutineScope.launch {
val currentX = animOffsetX.value
val thresholdPx = with(density) { threshold.toPx() }
if (abs(currentX) > thresholdPx) {
val targetX = if (currentX > 0) size.width * 2f else -size.width * 2f
launch { animOffsetX.animateTo(targetX, spring(stiffness = Spring.StiffnessMediumLow)) }
launch { animOffsetY.animateTo(animOffsetY.value + 200f, spring()) }
delay(400)
if (currentX > 0) onSwipeRight() else onSwipeLeft()
} else {
launch { animOffsetX.animateTo(0f, spring(dampingRatio = Spring.DampingRatioMediumBouncy)) }
launch { animOffsetY.animateTo(0f, spring(dampingRatio = Spring.DampingRatioMediumBouncy)) }
}
}
}
)
}
) {
CardContent(card = card)
}
}
Card Stack
Managing the stack—LazyColumn doesn't work: cards overlap. Use Box (ZStack) with zIndex:
Box {
cards.takeLast(3).reversed().forEachIndexed { index, card ->
val stackIndex = 2 - index // 0 = bottom, 2 = top
SwipeCard(
card = card,
scale = 1f - (stackIndex * 0.05f),
verticalOffset = (stackIndex * 12).dp,
zIndex = stackIndex.toFloat(),
onSwipe = { direction -> handleSwipe(card, direction) }
)
}
}
On top card dismiss, animate scale and offset of remaining cards via animateFloatAsState.
Flutter: Dismissible and Custom GestureDetector
Dismissible is Flutter's built-in swipe widget but only horizontal/vertical without rotation. For full Tinder pattern, use custom GestureDetector + AnimationController similar to iOS.
Library flutter_card_swiper: ^7.0.0 covers most cases without custom work.
Timeline
Basic card stack swipe (one platform): 1–2 days. With undo animation, custom overlays, and vertical swipe support: 2–3 days. Cost is calculated individually.







