What You Will Build
A stack of cards where the top card can be swiped left or right with drag gestures. Swiping past a threshold removes the card from the stack, revealing the next one. The top card rotates based on drag distance, and background cards are slightly smaller and offset to create a stacked appearance.
Why This Pattern Matters
The swipe-to-dismiss card stack (popularized by Tinder) is used in decision-making UIs, content discovery, and quiz apps. Building it teaches horizontal drag gesture handling, stack z-ordering, and spring-based snap-back animations.
Step 1: Define the Card Model
data class SwipeCard(
val id: Int,
val title: String,
val desc: String,
val color: Color
)
Step 2: Build the Swipeable Card Stack
@Composable
fun CardSwipeScreen() {
val cards = remember {
mutableStateListOf(
SwipeCard(1, "Explore", "Discover new places", Color(0xFF6366F1)),
SwipeCard(2, "Connect", "Meet new people", Color(0xFFEC4899)),
SwipeCard(3, "Create", "Build something great", Color(0xFF10B981)),
SwipeCard(4, "Learn", "Expand your knowledge", Color(0xFFF59E0B)),
SwipeCard(5, "Share", "Spread the word", Color(0xFF3B82F6))
)
}
Box(
modifier = Modifier.width(300.dp).height(400.dp),
contentAlignment = Alignment.Center
) {
cards.reversed().forEachIndexed { reversedIndex, card ->
val index = cards.size - 1 - reversedIndex
val isTop = index == 0
var offsetX by remember { mutableFloatStateOf(0f) }
val animatedOffset by animateFloatAsState(
offsetX,
spring(dampingRatio = 0.7f),
label = "off"
)
Surface(
modifier = Modifier
.width((280 - index * 10).dp)
.height((380 - index * 8).dp)
.offset {
IntOffset(
if (isTop) animatedOffset.roundToInt() else 0,
index * -8
)
}
.zIndex((cards.size - index).toFloat())
.graphicsLayer {
if (isTop) rotationZ = animatedOffset / 20f
scaleX = 1f - index * 0.03f
scaleY = 1f - index * 0.03f
}
.then(
if (isTop) Modifier.pointerInput(card.id) {
detectHorizontalDragGestures(
onDragEnd = {
if (abs(offsetX) > 300) {
cards.removeAt(0)
}
offsetX = 0f
},
onHorizontalDrag = { _, amount ->
offsetX += amount
}
)
} else Modifier
),
shape = RoundedCornerShape(20.dp),
color = card.color,
shadowElevation = (8 - index * 2).coerceAtLeast(0).dp
) {
Column(
modifier = Modifier.fillMaxSize().padding(28.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(card.title, color = Color.White,
fontSize = 32.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp))
Text(card.desc,
color = Color.White.copy(alpha = 0.8f),
fontSize = 16.sp)
}
}
}
}
}
Tips and Pitfalls
- Swipe threshold of 300 pixels prevents accidental dismissals. Adjust based on screen width.
- Rotation = offset / 20f creates a natural tilt. Higher divisors reduce tilt sensitivity.
- Use mutableStateListOf instead of a regular list so Compose observes removals and triggers recomposition.
- Only attach gesture to the top card using
if (isTop)to prevent dragging background cards.