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.

Get New Tutorials by Email

No spam. Just clear, practical breakdowns you can apply right away.

Enjoy this tutorial?

Get new practical tech tutorials in your inbox.