---
title: "Compose Animation Masterclass: From Micro-Interactions to Shared Element Transitions"
description: "Build polished, performant Jetpack Compose animations with animate*AsState, AnimatedVisibility, shared element transitions, gestures, and physics springs."
url: https://android-x.com/blog/compose-animation-masterclass
author: "Rocky Elsalaymeh"
publisher: "Strategia-X (ANDROID-ARCHITECT)"
published: 2026-03-16
updated: 2026-10-11
category: "Jetpack Compose"
reading_time: "15 min read"
---

# Compose Animation Masterclass: From Micro-Interactions to Shared Element Transitions

> Build polished, performant Jetpack Compose animations with animate*AsState, AnimatedVisibility, shared element transitions, gestures, and physics springs.

By Rocky Elsalaymeh. Published 2026-03-16, updated 2026-10-11. Canonical page: https://android-x.com/blog/compose-animation-masterclass

## Which Compose animation API should you use?

Animation is information. It tells the user what changed, where something came from and what will happen if they let go. Used that way it earns its frames. Used as decoration it costs battery and attention.

The earlier version of this article opened with a statistic, that apps with good motion "score 23% higher" on perceived quality, attributed to Google research. We could not find that research, so the number is gone, along with a closing claim about Google benchmarks that had the same problem.

What can be sourced is the API map. Compose's [animation APIs](https://developer.android.com/develop/ui/compose/animation/introduction) are layered, and choosing the layer is most of the work ([quick guide](https://developer.android.com/develop/ui/compose/animation/quick-guide)):

| You want to | Use | Level |
|---|---|---|
| Animate one value toward a target | `animate*AsState` | High |
| Show or hide a composable | `AnimatedVisibility` | High |
| Swap content when state changes | `AnimatedContent` | High |
| Animate several values between states together | `updateTransition` | High |
| Loop forever | `rememberInfiniteTransition` | High |
| Drive a value from a gesture or coroutine | `Animatable` | Low |

Start at the top of the table. Drop to `Animatable` only when a finger is involved or you need to sequence by hand.

## Micro-Interactions with animate*AsState

The [value-based guide](https://developer.android.com/develop/ui/compose/animation/value-based) describes this family as the simple case: "You provide only the target value (or end value), and the API starts animation from the current value to the specified value." There is nothing to start, stop or clean up: "You don't need to create an instance of any animation class or handle interruption."

Two habits matter here. Pass a `label`, which is what tooling shows. And apply the animated value in a lambda modifier such as `graphicsLayer { }`, the form the documentation's own sample uses. The performance section explains why that second habit is the difference between a cheap animation and an expensive one.

One correction to the earlier sample: it showed an `animateIntAsState` counter and described it as rolling digits up and down. It does not. It interpolates the number, so the text flickers through intermediate values. Content that should slide between states is a job for `AnimatedContent`, which "animates its content as it changes based on a target state".

```kotlin
@Composable
fun LikeButton(isLiked: Boolean, onToggle: () -> Unit) {
    val scale by animateFloatAsState(
        targetValue = if (isLiked) 1.0f else 0.85f,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness = Spring.StiffnessLow
        ),
        label = "likeScale"
    )
    val tint by animateColorAsState(
        targetValue = if (isLiked) Color(0xFFE53935) else Color(0xFF9E9E9E),
        animationSpec = tween(durationMillis = 300),
        label = "likeTint"
    )

    IconButton(onClick = onToggle) {
        Icon(
            imageVector = if (isLiked) Icons.Filled.Favorite
                          else Icons.Outlined.FavoriteBorder,
            contentDescription = if (isLiked) "Unlike" else "Like",
            tint = tint,
            // Read in the draw phase, not in composition
            modifier = Modifier.graphicsLayer {
                scaleX = scale
                scaleY = scale
            }
        )
    }
}

// A counter whose digits slide: AnimatedContent, not animateIntAsState
@Composable
fun AnimatedCounter(count: Int) {
    AnimatedContent(
        targetState = count,
        label = "counter"
    ) { targetCount ->
        // Use targetCount here, not count
        Text(
            text = "$targetCount",
            style = MaterialTheme.typography.headlineLarge
        )
    }
}
```

## AnimatedVisibility: Enter and Exit Choreography

An `if` removes content in one frame. [AnimatedVisibility](https://developer.android.com/develop/ui/compose/animation/composables-modifiers) keeps it in the composition long enough to animate out. The defaults are sensible: "By default, the content appears by fading in and expanding, and it disappears by fading out and shrinking." You replace them by combining `EnterTransition` and `ExitTransition` objects with `+`.

For lists, do not build staggered entrances by hand. The earlier sample here kept a `visible` flag per row and flipped it after a delay in a `LaunchedEffect`. In a lazy list that state is lost when a row scrolls away, so the entrance replays on every scroll back. Lazy layouts have an API for item motion: "you just need to set the animateItem modifier to the item content", and it requires keys, because "It is important to provide a key to each item to ensure animateItem() works as expected" ([lists guide](https://developer.android.com/develop/ui/compose/lists)). The same keys are what make [paged lists](https://android-x.com/blog/paging3-compose-infinite-lists) cheap to update.

```kotlin
@Composable
fun NotificationBanner(
    message: String,
    isVisible: Boolean,
    onDismiss: () -> Unit
) {
    AnimatedVisibility(
        visible = isVisible,
        enter = slideInVertically(
            initialOffsetY = { -it }, // from above
            animationSpec = spring(
                dampingRatio = Spring.DampingRatioLowBouncy
            )
        ) + fadeIn(animationSpec = tween(300)),
        exit = slideOutVertically(
            targetOffsetY = { -it },
            animationSpec = tween(200)
        ) + fadeOut(animationSpec = tween(150))
    ) {
        Card(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp)
        ) {
            Row(
                modifier = Modifier.padding(16.dp),
                verticalAlignment = Alignment.CenterVertically
            ) {
                Text(message, modifier = Modifier.weight(1f))
                IconButton(onClick = onDismiss) {
                    Icon(Icons.Default.Close, contentDescription = "Dismiss")
                }
            }
        }
    }
}

// List item motion: keys plus animateItem
@Composable
fun ProductList(products: List<Product>) {
    LazyColumn {
        items(products, key = { it.id }) { product ->
            ProductRow(
                product = product,
                modifier = Modifier.animateItem()
            )
        }
    }
}
```

## Shared Element Transitions

A shared element transition carries one piece of UI, a thumbnail or a title, from its place on one screen to its place on the next. The [shared elements guide](https://developer.android.com/develop/ui/compose/animation/shared-elements) names the three parts:

- `SharedTransitionLayout`: "The outermost layout required to implement shared element transitions."
- `Modifier.sharedElement()`: marks "the composable that should be matched with another composable."
- `Modifier.sharedBounds()`: for a container whose content differs on each side. "In contrast to sharedElement(), sharedBounds() is designed for visually different content."

Both sides must use the same key through `rememberSharedContentState()`, and both need the scope of whatever is animating between them. With Navigation Compose the wiring is documented: "you must first wrap your app's NavHost in a SharedTransitionLayout", and inside a destination "you can use this@composable to reference that scope" ([with navigation](https://developer.android.com/develop/ui/compose/animation/shared-elements/navigation)). The same page covers Navigation 3, which matters now that [Navigation Compose is in maintenance mode](https://android-x.com/blog/compose-navigation-type-safe).

Order in the modifier chain is not cosmetic: "Put anything you don't want to be shared before the sharedElement() in the modifier chain." The earlier sample here passed the state as `state =`, a parameter name the current samples do not use. The version below passes it positionally, as the guide does.

```kotlin
@Composable
fun AppNavHost(navController: NavHostController) {
    SharedTransitionLayout {
        NavHost(navController, startDestination = ProductList) {
            composable<ProductList> {
                ProductListScreen(
                    onProductClick = { id ->
                        navController.navigate(ProductDetail(id))
                    },
                    sharedTransitionScope = this@SharedTransitionLayout,
                    animatedVisibilityScope = this@composable
                )
            }

            composable<ProductDetail> { backStackEntry ->
                val route: ProductDetail = backStackEntry.toRoute()
                ProductDetailScreen(
                    productId = route.productId,
                    sharedTransitionScope = this@SharedTransitionLayout,
                    animatedVisibilityScope = this@composable
                )
            }
        }
    }
}

@Composable
fun ProductThumbnail(
    product: Product,
    sharedTransitionScope: SharedTransitionScope,
    animatedVisibilityScope: AnimatedVisibilityScope,
    modifier: Modifier = Modifier
) {
    with(sharedTransitionScope) {
        AsyncImage(
            model = product.imageUrl,
            contentDescription = product.name,
            modifier = modifier
                // Same key on the list screen and the detail screen
                .sharedElement(
                    rememberSharedContentState(key = "image-${product.id}"),
                    animatedVisibilityScope = animatedVisibilityScope
                )
                .size(100.dp)
                .clip(CircleShape)
        )
    }
}
```

## Gesture-Driven Animation with Animatable

When motion has to follow a finger, the high-level APIs run out. `Animatable` is the layer underneath them: "a value holder that can animate the value as it is changed using animateTo. This is the API backing up the implementation of animate*AsState." It "ensures consistent continuation and mutual exclusiveness", which is what lets a drag interrupt a running animation without a jump.

The pattern from the [advanced guide](https://developer.android.com/develop/ui/compose/animation/advanced) is three moves: `stop()` the animation on touch down, `snapTo()` during the drag, then `animateTo()` or `animateDecay()` on release.

The earlier sample in this section was wrong in a way worth naming. It launched every animation with `CoroutineScope(Dispatchers.Main).launch`, a new unowned scope per drag event. Nothing cancels those when the composable leaves, which is exactly the leak [structured concurrency](https://android-x.com/blog/kotlin-coroutines-deep-dive) exists to prevent. `pointerInput` already gives you a suspending block. Open a `coroutineScope` inside it and launch there, as the documentation does, and everything is cancelled with the modifier.

A spring is the right spec for the snap back. "spring can handle interruptions more smoothly than duration-based AnimationSpec types because it guarantees the continuity of velocity" ([customize](https://developer.android.com/develop/ui/compose/animation/customize)).

```kotlin
// Adapted from the swipeToDismiss sample in the Android docs
fun Modifier.swipeToDismiss(onDismissed: () -> Unit): Modifier = composed {
    val offsetX = remember { Animatable(0f) }

    pointerInput(Unit) {
        val decay = splineBasedDecay<Float>(this)

        // One scope, owned by pointerInput. No CoroutineScope(...).
        coroutineScope {
            while (true) {
                val velocityTracker = VelocityTracker()
                offsetX.stop() // interrupt any running animation

                awaitPointerEventScope {
                    val pointerId = awaitFirstDown().id
                    horizontalDrag(pointerId) { change ->
                        launch {
                            offsetX.snapTo(
                                offsetX.value + change.positionChange().x
                            )
                        }
                        velocityTracker.addPosition(
                            change.uptimeMillis, change.position
                        )
                    }
                }

                val velocity = velocityTracker.calculateVelocity().x
                val target = decay.calculateTargetValue(offsetX.value, velocity)

                offsetX.updateBounds(
                    lowerBound = -size.width.toFloat(),
                    upperBound = size.width.toFloat()
                )
                launch {
                    if (target.absoluteValue <= size.width) {
                        // Not enough velocity: slide back
                        offsetX.animateTo(0f, initialVelocity = velocity)
                    } else {
                        // Swiped away
                        offsetX.animateDecay(velocity, decay)
                        onDismissed()
                    }
                }
            }
        }
    }.offset { IntOffset(offsetX.value.roundToInt(), 0) }
}
```

## Performance: Keeping Animation Off the Recomposition Path

An animation changes a value on every frame. What that costs depends on where the value is read.

1. **Read animated values in a lambda modifier.** `Modifier.graphicsLayer { alpha = animatedAlpha }` and `Modifier.offset { ... }` read state during draw or layout. The value form, `Modifier.alpha(animatedAlpha)`, reads it during composition and recomposes the function every frame. The general rule is in the [best practices guide](https://developer.android.com/develop/ui/compose/performance/bestpractices): "Deferring state reads will ensure that Compose re-runs the minimum possible code on recomposition."
2. **Prefer transforms to layout changes.** Scale, translation, rotation and alpha in a graphics layer do not re-measure anything. Animating a width or a padding does, on every frame. When the size really must change, use `Modifier.animateContentSize()`, which exists for that case.
3. **Remember that alpha zero is still there.** The quick guide's caveat: after fading out, "the composable remains in the composition and continues to occupy the space it's laid out in." Screen readers can still reach it. Use `AnimatedVisibility` when the content should actually go.
4. **Label animations.** In Animation Preview, each transition and value "is labeled with a default name", and you set a useful one with the `label` parameter ([tooling](https://developer.android.com/develop/ui/compose/animation/tooling)).
5. **Measure on a release build.** The rest of the method is in the [Compose performance guide](https://android-x.com/blog/jetpack-compose-performance).

## Key takeaways

- Pick the highest-level API that fits: animate*AsState, AnimatedVisibility, AnimatedContent, then Animatable for gestures.
- Apply animated values in lambda modifiers such as graphicsLayer { } so they are read in draw, not composition.
- Animate list items with keys and Modifier.animateItem(), not with per-row visibility flags.
- Shared elements need SharedTransitionLayout, the same key on both sides, and the animating scope passed to each.
- Launch gesture animations inside pointerInput with coroutineScope. Never create a CoroutineScope per drag event.
- Use spring for anything a user can interrupt: it keeps velocity continuous where a tween cannot.

## Frequently asked questions

### How do I avoid animation performance problems in Compose?

Read the animated value as late as possible. Apply it inside a lambda modifier such as graphicsLayer or offset, so the read happens during draw or layout and composition is skipped. Prefer scale, translation and alpha to animating size or padding, which forces a layout pass on every frame of the animation.

### Should I use spring or tween?

Use spring for anything a user can interrupt, such as a press, a drag release or a toggle. The documentation notes that spring handles interruption more smoothly because it keeps velocity continuous. Use tween when you need an exact duration, for example to line an animation up with another timed event.

### How do shared element transitions work with navigation?

Wrap the NavHost in a SharedTransitionLayout and pass its scope to each screen, together with the destination scope, which you reference as this@composable. Apply Modifier.sharedElement with the same key on the matching composable in both screens. Compose then animates position and size between the two automatically during the navigation.

## Related articles

- [Jetpack Compose Performance: Eliminating Jank and Unnecessary Recompositions](https://android-x.com/blog/jetpack-compose-performance.md)
- [Type-Safe Navigation in Jetpack Compose: From Strings to Kotlin Serialization](https://android-x.com/blog/compose-navigation-type-safe.md)
- [Building a Design System with Jetpack Compose](https://android-x.com/blog/jetpack-compose-design-system.md)
