Swipeable Cards

A Tinder style card stack. Drag past the threshold and the card flings off, tilted by a rotation factor proportional to the offset.

Compose APIs
AnimatablepointerInputgraphicsLayerspring
Try tweaking
SWIPE_THRESHOLD_FRACTIONROTATION_FACTORFLING_STIFFNESSFLING_DURATION_MS

Swipeable cards bring together three things that look unrelated until you build them: continuous drag tracking, a release time decision based on how far the user moved, and an animated dismissal that finishes the gesture. Compose handles each of these with focused tools, and stitching them together produces an interaction that feels physical rather than scripted. The top card holds an Animatable<Float> that mirrors the finger during the drag, the threshold check turns the user's intent into a binary choice, and a spring or tween carries the card to its final resting place. The rotation comes for free, derived from the same offset that drives the translation.

In this article, you'll explore how AnimationExample13 builds a swipeable card stack with Animatable, pointerInput with detectDragGestures, graphicsLayer for visual coupling, and spring and tween specs for the release animations.

How the example is structured

The composable lays out a vertical column with a header and a BoxWithConstraints that hosts the stack. BoxWithConstraints exposes maxWidth, which the code converts into pixels through LocalDensity.current. That width is the basis for the swipe threshold.

The stack itself is just a few Card composables drawn inside the same Box. Two peek cards sit behind the active one with a downward offset and a slight scale reduction, then the top card is drawn last so it visually wins the z order. The active card is the only one that listens for input. State is minimal: a topIndex integer that moves forward each time a card flies off, and a single Animatable(0f) that holds the horizontal offset of the top card.

var topIndex by remember { mutableStateOf(0) }
val offsetX = remember { Animatable(0f) }
val scope = rememberCoroutineScope()

Because offsetX is reused across cards, the example resets it back to 0f after each successful swipe, then advances topIndex. The next card now reads offsetX.value of zero and starts from center.

Tracking the drag with Animatable.snapTo

Drag tracking lives inside a pointerInput block. The pointer modifier is keyed on topIndex and the tweakable constants so that changing them tears down and re registers the gesture detector with fresh values, which avoids stale closures.

.pointerInput(topIndex, SWIPE_THRESHOLD_FRACTION, FLING_STIFFNESS, FLING_DURATION_MS) {
  detectDragGestures(
    onDrag = { change, drag ->
      change.consume()
      scope.launch { offsetX.snapTo(offsetX.value + drag.x) }
    },

detectDragGestures reports each pointer movement as a delta in drag.x. The handler calls change.consume() so the parent layout does not receive the same event, then launches a coroutine that calls offsetX.snapTo(offsetX.value + drag.x). snapTo is the right choice here because it sets the value immediately without running an animation. The finger and the card stay in lockstep at one to one. If you used animateTo per delta you would queue dozens of micro animations per second and the card would lag behind the touch.

Because Animatable cancels any in flight animation when snapTo is called, a user can grab the card mid spring back, and the new drag takes over without any seam. That is the reason the example uses Animatable at all instead of a plain mutableStateOf(Float).

Threshold and decision on release

When the finger lifts, onDragEnd runs. The decision is binary: either the card has crossed the swipe threshold and should fling off, or it has not and should spring back to center.

onDragEnd = {
  scope.launch {
    if (abs(offsetX.value) > threshold) {
      val target = if (offsetX.value > 0) widthPx * 1.5f else -widthPx * 1.5f
      offsetX.animateTo(target, animationSpec = tween(FLING_DURATION_MS))
      topIndex += 1
      offsetX.snapTo(0f)
    } else {
      offsetX.animateTo(0f, spring(stiffness = FLING_STIFFNESS))
    }
  }
},

threshold is computed once per layout pass as widthPx * SWIPE_THRESHOLD_FRACTION, where widthPx comes from BoxWithConstraints. With the default SWIPE_THRESHOLD_FRACTION = 0.3f, the user must drag the card at least 30 percent of the container width before a release counts as a commit. Using a fraction instead of a fixed pixel value keeps the feel consistent across phone widths and tablet widths.

The sign of offsetX.value chooses the direction of the fling. After the fling completes, the code increments topIndex and snaps offsetX back to zero so the next card starts centered. The order matters: increment after animateTo returns, because animateTo is a suspend function that completes only when the animation finishes.

Fling animation with spring

The dismissal uses tween(FLING_DURATION_MS) to drive the card to a target far outside the visible area. The default FLING_DURATION_MS = 300 gives a clean exit, fast enough to feel responsive but slow enough that the user sees the card leave.

val target = if (offsetX.value > 0) widthPx * 1.5f else -widthPx * 1.5f
offsetX.animateTo(target, animationSpec = tween(FLING_DURATION_MS))

The target is widthPx * 1.5f rather than widthPx. Picking 1.5 times the container width guarantees the card is fully off screen even after the rotation tilts it. A card rotated by twenty degrees has a wider bounding box than the unrotated card, and the corner can still peek into view if the target is exactly widthPx. The 1.5 multiplier provides a margin that absorbs that geometry.

The spring back path uses a different spec. spring(stiffness = FLING_STIFFNESS) produces a physical recoil with a small overshoot, which reads as the card resisting the swipe and snapping back into place. With FLING_STIFFNESS = 300f, the default Spring.StiffnessMedium neighborhood, the card returns in roughly a third of a second with a soft settle.

Two specs for two intents: a deterministic tween for the commit because exit timing should be predictable, and a spring for the cancel because a physical recoil reinforces that nothing changed.

Visual coupling: rotation as a function of offset

The tilt is not animated independently. It reads the current offsetX.value and multiplies by a constant inside graphicsLayer.

.offset { IntOffset(offsetX.value.toInt(), 0) }
.graphicsLayer { rotationZ = offsetX.value * ROTATION_FACTOR }

graphicsLayer accepts a lambda that runs in the draw phase, which means it reads offsetX.value every frame after layout completes. There is no second Animatable for rotation and no second coroutine driving it. Whatever the offset is on a given frame, the rotation is a pure function of it. With ROTATION_FACTOR = 0.15f, a card pulled 200 pixels to the right rotates 30 degrees clockwise, and a card pulled 100 pixels to the left rotates 15 degrees counterclockwise.

Driving rotation off the same source as translation means the spring back animation also unwinds the rotation for free. As offsetX returns to zero, rotationZ returns to zero on the same frames. Two visual properties stay perfectly in sync because they share one source of truth.

offset { IntOffset(offsetX.value.toInt(), 0) } uses the lambda form of offset for the same reason. The lambda runs in the layout phase and reads offsetX.value on each frame without invalidating composition.

Tweaking threshold, rotation, fling spec

Each of the four constants at the top of the function controls one axis of the feel.

SWIPE_THRESHOLD_FRACTION sets how committed the user must be. At 0.1f the card flies off after a flick of the wrist, at 0.6f the user must drag the card more than halfway across the screen before release counts. The default 0.3f lands in a comfortable middle: confident enough that accidental nudges spring back, forgiving enough that an intentional swipe always takes.

ROTATION_FACTOR controls the visual punch. At 0f the card slides without tilting, which feels mechanical. At 0.3f the card spins aggressively and can flip past 45 degrees during a long drag. The default 0.15f gives a Tinder like tilt that signals direction without becoming a pinwheel.

FLING_STIFFNESS controls the spring back feel. Lower values make the card amble back to center with a soft bounce, higher values snap it back fast and crisp. The default 300f is a brisk return without overshoot that draws attention.

FLING_DURATION_MS controls how long the commit exit takes. At 100 the card barely registers as it leaves, at 600 it drifts off as if the user is watching paint dry. The default 300 matches the spring back duration, so commit and cancel feel like siblings rather than strangers.

Conclusion

In this article, you've explored how AnimationExample13 composes a swipeable card stack from a single Animatable, a detectDragGestures block, and two animation specs. The drag updates the offset with snapTo for one to one tracking, onDragEnd reads the magnitude against a width relative threshold, and the result is either a tween driven exit far past the screen edge or a spring driven return to zero. Rotation is computed in graphicsLayer from the same offset, so the tilt always matches the position.

Understanding this structure helps you build other gesture driven dismissals without reaching for a third party library. The pattern of "one Animatable for the source, derived visuals in graphicsLayer, decision in onDragEnd" applies directly to swipe to delete rows, dismissible bottom sheets, and pull to refresh hooks. The choice between snapTo, animateTo with spring, and animateTo with tween is not stylistic. It encodes the difference between tracking input, settling toward a rest state, and committing to a deterministic exit.

Whether you are building a card based onboarding flow, a swipe to dismiss notification surface, or a custom carousel with momentum, the same three pieces apply: a single Animatable for the dominant axis, a threshold expressed as a fraction of the container, and an animation spec chosen to match user intent. Keep the rotation, scale, and alpha derived from the same source and the entire gesture stays coherent across drag, release, and rebound.

As always, happy coding!

Jaewoong (skydoves)