Spring Drag Box

A box you can drag with pointerInput, then release to spring back to origin via Animatable.animateTo.

Compose APIs
AnimatablepointerInputspring
Try tweaking
SPRING_STIFFNESSSPRING_DAMPINGrelease target position

Gesture driven motion sits at an awkward intersection in Compose. While the finger is down, position must follow the pointer one to one with no interpolation in the way. The moment the finger lifts, that same position should ease back to its origin under physics. You need a single state holder that can be both manually written and animated, and you need it to be safe to drive from coroutines launched on every drag event. The Compose answer is Animatable.

In this article, you'll explore a concrete example that pairs pointerInput with Animatable to build a draggable box that springs home on release. You'll see how snapTo and animateTo divide the work between the drag phase and the release phase, how detectDragGestures feeds raw deltas into the animation state, and how the spring animation spec turns two numbers into the feel of a physical material.

How the example is structured

The composable holds two Animatable<Float, AnimationVector1D> values, one for the X axis and one for the Y axis. A single Box reads those values inside a Modifier.offset lambda and translates itself by the rounded result. A pointerInput modifier on the same box detects drag gestures, mutating the animatables during the drag and animating them back to zero on release.

The tweakable surface is small and lives at the top of the function:

val SPRING_STIFFNESS = 600f // try 50f (loose) / 1500f (snappy)
val SPRING_DAMPING = 0.55f // try 0.2f (very bouncy) / 1.0f (no bounce)
val BOX_COLOR = Color(0xFF26A69A)
val BOX_SIZE_DP = 80.dp
val BOX_CORNER_DP = 16.dp

SPRING_STIFFNESS and SPRING_DAMPING are passed both into the pointerInput key set and into the eventual spring(...) call. Keying pointerInput on these constants ensures that when you tweak a value during hot reload, the gesture detector restarts with the new physics rather than capturing stale ones.

The LaunchedEffect(SPRING_STIFFNESS, SPRING_DAMPING) block snaps the offsets back to zero whenever those constants change. That gives you a clean baseline after each tweak so you are not comparing a freshly recomposed spring against a box that is still mid flight from the previous configuration.

Animatable: the bridge between gestures and animations

Animatable is a single value holder that supports two distinct mutation paths: instantaneous writes and animated transitions. Both are suspending, which is what makes it safe to call from coroutines launched on every pointer event.

val offsetX = remember { Animatable(0f) }
val offsetY = remember { Animatable(0f) }
val scope = rememberCoroutineScope()

Two scalar animatables are used here instead of a single Animatable<Offset, AnimationVector2D>. Both modeling choices are valid. Splitting them keeps each axis independent, which means the X spring and the Y spring can progress on different coroutines and even land on slightly different frames without coordination overhead.

Animatable exposes two methods that matter for this example:

  • snapTo\(value\): writes the new value immediately, with no interpolation. Used while the finger is on the screen so the box tracks the pointer one to one.
  • animateTo\(target, animationSpec\): drives the value from its current position toward target over time, applying the given spec each frame. Used on release so the box flies back under spring physics.

Both methods are conflate safe across coroutine boundaries. If a new snapTo arrives while a previous animateTo is still running, the running animation cancels and the new mutation wins. This is exactly the behavior you want when the user grabs a box that is still springing back: the drag should take over instantly without any tug of war between the gesture and the in flight animation.

Reading drag deltas through pointerInput

detectDragGestures is a high level helper inside pointerInput that turns raw pointer events into per frame deltas. The example uses two of its callbacks: onDrag for the active phase and onDragEnd for release.

detectDragGestures(
  onDrag = { change, dragAmount ->
    change.consume()
    scope.launch {
      offsetX.snapTo(offsetX.value + dragAmount.x)
      offsetY.snapTo(offsetY.value + dragAmount.y)
    }
  },
  onDragEnd = { /* spring back */ },
)

Three details deserve attention. First, change.consume() marks the pointer change as handled so ancestor modifiers do not also try to interpret it as a scroll or another gesture. Second, the new offset is computed as current + delta rather than as an absolute position. dragAmount is already a per frame vector in pixels, so accumulation is the natural composition. Third, the snapTo calls happen inside scope.launch because snapTo is a suspending function and onDrag is not a suspend lambda.

The canonical pattern across most Compose drag examples is exactly this shape: launch a coroutine, snap the animatable to its current value plus the incoming delta. Conflation inside Animatable guarantees that even if drag events arrive faster than the animation loop can process them, the latest value wins and no intermediate snapshot leaves the box in a wrong place.

Spring physics on release

When the gesture ends, onDragEnd launches two parallel animations, one per axis, both targeting 0f:

onDragEnd = {
  scope.launch {
    launch {
      offsetX.animateTo(
        targetValue = 0f,
        animationSpec = spring(
          stiffness = SPRING_STIFFNESS,
          dampingRatio = SPRING_DAMPING,
        ),
      )
    }
    launch {
      offsetY.animateTo(
        targetValue = 0f,
        animationSpec = spring(
          stiffness = SPRING_STIFFNESS,
          dampingRatio = SPRING_DAMPING,
        ),
      )
    }
  }
}

The outer scope.launch exists because onDragEnd is again a non suspending lambda. The two inner launch calls run the X and Y animations concurrently, so the box does not finish its horizontal motion before starting its vertical motion. Since each axis owns its own Animatable, they do not need to coordinate.

The interesting part is the spring spec itself. A spring in Compose is parameterized by two numbers:

  • stiffness: how strongly the spring pulls toward the target. Higher stiffness means a faster return. The example uses 600f. The hint in the source suggests 50f for a loose, slow spring and 1500f for a snappy one.
  • dampingRatio: how much resistance the system has against oscillation. A value of 1.0f is critically damped, meaning the spring settles at the target without ever overshooting. Values below 1.0f allow overshoot and oscillation, with smaller numbers meaning more wobble. The example uses 0.55f, which is well into bouncy territory but still settles in a few cycles.

Together these two numbers describe the entire motion. Compose computes the analytical solution to the underlying second order differential equation, so the animation finishes exactly when the math says the spring has come to rest. There is no hand tuned duration to keep in sync.

Tweaking stiffness, damping, target

Because the example exposes both physics constants and the release target as plain values, you can change the feel by editing one number at a time.

Stiffness changes the time scale. Drop it to 50f and the box drifts back over what feels like a full second, ideal for cards or onboarding hints where the motion itself is the message. Push it up to 1500f and the snap back happens in a frame or two, which suits utility controls where the user wants confirmation that the gesture released.

Damping ratio changes the character of the motion. At 0.2f the box overshoots dramatically and oscillates several times before settling. At the example value of 0.55f you get one visible overshoot and a small secondary bounce. At 1.0f the box glides into place without ever crossing the target. Anything above 1.0f is overdamped and feels sluggish.

The release target is hard coded to Offset.Zero here through the two 0f arguments to animateTo. Swap those for a different value and the box springs to that point instead. A common variation is to spring back to the nearest grid intersection by computing targetValue = round(offsetX.value / gridSize) * gridSize, which gives you snapping behavior with the same physics.

Conclusion

In this article, you've explored how a small set of Compose primitives compose into a tactile drag and release interaction. Animatable provides the dual mode state holder that accepts both immediate writes during a drag and physics driven transitions on release. pointerInput with detectDragGestures feeds per frame deltas into that state, and spring translates two physical parameters into the entire return motion.

Understanding these internals helps you reason about why gesture animations feel right or wrong in your own code. If a snap back feels late, the animation is probably duration based when it should be spring based. If a drag jitters, the gesture is probably writing to a mutableStateOf instead of an Animatable, which means a competing animation is fighting your input. The split between snapTo and animateTo is the contract that keeps these two phases from interfering with each other.

Whether you're building a draggable card stack, a swipe to dismiss row, or a custom slider with a magnetic detent, the pattern is the same: hold position in an Animatable, snap during the gesture, and animate with a spring on release. Tune stiffness for speed, damping for bounce, and choose your release target to match the affordance you want the user to feel.

As always, happy coding!

Jaewoong (skydoves)