Card Swipe Animation: Tinder-style with Spring Physics
The card stack with swipe is a well-established pattern not only in dating apps. Product selection, content rating, quiz interfaces — it works everywhere a quick binary decision is needed. The technical challenge: the card follows the finger, rotates proportionally to horizontal displacement (typical angle 15-25°), when reaching the threshold (40% of screen width, velocity > 800 dp/s) it flies off to the side, and the next card scales to 95% and lifts by 20 dp. Without proper spring animation, the user feels a "woodenness" — the card is either too sluggish or flies off like plastic. Each animation requires precise tuning of mass (0.8–1.2), stiffness (150–300), and damping (20–30) for natural behavior. We have implemented over 30 projects with such animations, accumulating experience in tuning physical parameters for unique UX.
Problems We Solve
- Rotation and threshold. The rotation angle must depend proportionally on horizontal displacement, and the threshold on velocity, so that short sharp swipes trigger correctly. An error in the coefficient (e.g., 10° instead of 25°) makes the animation unnatural.
- Stack animation. The lower cards must scale and lift when the top card is dismissed. Without a delay (100-200 ms), the stack feels jerky.
- Undo and reverse animation. On cancel, the card must smoothly return using spring animation with a damping ratio of 0.7-0.8.
Why Spring Animation is Better Than Standard?
Spring animation (mass-spring-damper) mimics a real throw, unlike linear interpolation. Users perceive it as more natural — retention improves by 10-15% according to A/B tests. Spring animation is 3 times more responsive to sharp movements because the physical model accounts for inertia and elasticity.
How Spring Parameters Affect Behavior
| Parameter | iOS (UISpringTimingParameters) | Android (Spring.DampingRatio) | Effect |
|---|---|---|---|
| mass | 0.8-1.2 | not used | Higher mass slows motion, making it smoother |
| stiffness | 150-300 | Spring.StiffnessMediumLow (200) | Higher stiffness speeds up return, but may cause snap |
| damping | 20-30 | 0.5-0.8 (dampingRatio) | Lower damping gives more bounce, higher causes smooth decay |
How We Do It: Code Breakdown
For in-depth study of UISpringTimingParameters refer to Apple Developer Documentation.
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: Jetpack Compose with Animatable
@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)
}
}
Flutter: Custom Implementation
Dismissible is a built-in Flutter widget with swipe, but only horizontal or vertical, without rotation. For a full Tinder pattern, use a custom GestureDetector + AnimationController similar to the iOS approach. The library flutter_card_swiper: ^7.0.0 covers most cases without reinventing the wheel.
How to Achieve Smooth Animation?
The key factor is using physics engines: UISpringTimingParameters on iOS, Spring.DampingRatio on Android, AnimationController with elasticOut curve. Our engineers tune mass, stiffness, damping parameters for the specific UX. This gives a physical feel to the card. We guarantee that after tuning the animation will run at 60 fps on devices from iPhone 8 and Android 8.
Typical Implementation Mistakes
- Incorrect velocity threshold (below 600 dp/s leads to accidental triggers, above 1200 dp/s requires too much effort).
- Missing scaling of the next card (on dismiss it should shrink by 5% and shift by 10-20 dp).
- Forgetting to set initialVelocity in the spring animation for return — without it the card stops abruptly.
- Using linear animation instead of spring — results in unnatural movement.
Comparison of Approaches by Complexity
| Platform | Basic Implementation Time | Customization Difficulty | Required Experience |
|---|---|---|---|
| iOS | 1-2 days | Low | Swift, UIKit/SwiftUI |
| Android | 1-2 days | Medium | Kotlin, Compose |
| Flutter | 0.5-1 day | Medium | Dart, widgets |
Process
- Analysis — define UX scenarios, target velocity (usually 500-1500 dp/s), rotation angle (15-25°), stack behavior.
- Design — create an animation prototype in Figma or After Effects.
- Implementation — write code using platform APIs (UISpringTimingParameters, Compose animation).
- Testing — test on real devices, measure fps (target 60 fps), adjust parameters.
- Deployment — release to TestFlight / Google Play Internal Testing.
Estimated Timelines
- Basic animation (swipe + rotation) — from 1 day.
- With undo, overlays, and complex stack — up to 3 days.
- Cost is calculated individually — contact us for a budget estimate.
What’s Included
- Source code with comments (iOS/Android/Flutter).
- Integration with existing API (if required).
- Documentation on parameter tuning.
- Support for 2 weeks after deployment.
Save up to 3 days of development time on animations. The development cost is calculated individually. Get a consultation from an engineer with over 5 years of mobile development experience. Order swipe card animation development, and we will tune the physics to your UX.







