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

Build polished, performant animations using animate*AsState, AnimatedVisibility, and physics-based springs.

Introduction

Animations enhance UX by providing visual feedback and guiding attention. Jetpack Compose provides declarative animation APIs that integrate with state. This guide covers micro-interactions, complex sequences, and shared element transitions.

State-Based Animations

Use animate*AsState for automatic animation on state change. Choose appropriate animation spec. Use spring for natural motion. Use tween for precise timing.

AnimatedVisibility and Enter/Exit

Wrap composables with AnimatedVisibility. Customize enter/exit animations. Chain multiple animations. Animate size changes with animateContentSize.

Gesture-Driven Animation

Use draggable and transformable gestures. Animate to position on gesture end. Use AnimationState for gesture tracking. Implement fling with decay animation.

Shared Element Transitions

Use sharedContentState for element transitions between screens. Match shared elements across composables. Configure transition spec. Handle size and position changes.

Frequently Asked Questions

How do I avoid animation performance issues?

Avoid animating large composition trees. Use graphicsLayer for complex animations. Profile with Layout Inspector. Use LaunchedEffect for animation coordination.

What's the best animation spec?

Spring for natural, responsive motion. Tween for precise, choreographed sequences. Use springDefaults for common patterns. Customize stiffness and damping for brand feel.

SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC07:37:35
UP00:01
MOD · ARTICLE · JETPACK COMPOSES/N · AX-COMPOSPUBLISHED
Jetpack ComposeMar 16, 202615 MIN

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 · Founder & Principal Consultant, Strategia-X

Animation as a UX Multiplier

Animation is not decoration -- it is information. A well-animated Android app communicates state changes, guides attention, establishes spatial relationships, and reduces cognitive load. Google's own research shows that apps with thoughtful motion design score 23% higher on perceived quality ratings, even when the underlying functionality is identical. Jetpack Compose provides a layered animation API that scales from simple property changes to complex choreographed sequences. The key is choosing the right API for each scenario (see the animation quick guide for a decision flowchart): - **animate*AsState**: For simple property animations (color, size, offset). Fires automatically when the target value changes. - **AnimatedVisibility**: For enter/exit transitions of entire composables. Handles the lifecycle of appearing/disappearing content. - **Animatable**: For imperative, coroutine-driven animations where you need precise control over timing and sequencing. - **Transition**: For coordinating multiple animations that should stay synchronized. - **InfiniteTransition**: For looping animations like pulsing indicators or shimmer effects. The performance cost of Compose animations is near zero when implemented correctly, because animations update only the specific composition nodes they affect -- no full recomposition triggered.

Micro-Interactions with animate*AsState

Micro-interactions are the small, immediate feedback animations that make an app feel alive: a button scaling on press, a color shifting on hover, an icon rotating on state change. In Compose, `animate*AsState` handles these with a single line of code. The family includes `animateDpAsState`, `animateColorAsState`, `animateFloatAsState`, `animateIntAsState`, and `animateSizeAsState`. Each watches its target value and smoothly interpolates when it changes.
kotlin
@Composable
fun LikeButton(isLiked: Boolean, onToggle: () -> Unit) {
    // Animate multiple properties simultaneously
    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"
    )
    val rotation by animateFloatAsState(
        targetValue = if (isLiked) 0f else -30f,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioHighBouncy
        ),
        label = "likeRotation"
    )

    IconButton(onClick = onToggle) {
        Icon(
            imageVector = if (isLiked) Icons.Filled.Favorite
                          else Icons.Outlined.FavoriteBorder,
            contentDescription = if (isLiked) "Unlike" else "Like",
            tint = tint,
            modifier = Modifier
                .scale(scale)
                .rotate(rotation)
        )
    }
}

// Animated counter that rolls digits up/down
@Composable
fun AnimatedCounter(count: Int) {
    val animatedCount by animateIntAsState(
        targetValue = count,
        animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing),
        label = "counter"
    )

    Text(
        text = "$animatedCount",
        style = MaterialTheme.typography.headlineLarge,
        fontWeight = FontWeight.Bold
    )
}

AnimatedVisibility: Enter and Exit Choreography

`AnimatedVisibility` manages the full lifecycle of composables that appear and disappear. Unlike a simple `if` statement that instantly removes content, `AnimatedVisibility` runs exit animations before removal and enter animations after insertion. You can compose multiple enter/exit effects: `fadeIn() + slideInVertically()` creates a fade-and-slide combination. The animations run in parallel by default, or you can sequence them with custom `AnimationSpec` delays.
kotlin
@Composable
fun NotificationBanner(
    message: String,
    isVisible: Boolean,
    onDismiss: () -> Unit
) {
    AnimatedVisibility(
        visible = isVisible,
        enter = slideInVertically(
            initialOffsetY = { -it }, // Slide in from top
            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),
            colors = CardDefaults.cardColors(
                containerColor = MaterialTheme.colorScheme.primaryContainer
            )
        ) {
            Row(
                modifier = Modifier.padding(16.dp),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically
            ) {
                Text(message, modifier = Modifier.weight(1f))
                IconButton(onClick = onDismiss) {
                    Icon(Icons.Default.Close, "Dismiss")
                }
            }
        }
    }
}

// Staggered list animation
@Composable
fun StaggeredAnimatedList(items: List<Product>) {
    LazyColumn {
        itemsIndexed(items) { index, product ->
            var visible by remember { mutableStateOf(false) }

            LaunchedEffect(Unit) {
                delay(index * 50L) // 50ms stagger per item
                visible = true
            }

            AnimatedVisibility(
                visible = visible,
                enter = fadeIn(tween(300)) +
                        slideInHorizontally(
                            initialOffsetX = { it / 3 },
                            animationSpec = tween(
                                durationMillis = 400,
                                easing = FastOutSlowInEasing
                            )
                        )
            ) {
                ProductCard(product)
            }
        }
    }
}

Shared Element Transitions

Shared element transitions create visual continuity between screens by morphing a common element (like a product image or card) from its position on one screen to its position on another. Compose's `SharedTransitionLayout` and `sharedElement` modifier make this possible with surprisingly little code. The key is wrapping your `NavHost` in a `SharedTransitionLayout` and marking elements that should transition with the `sharedElement` modifier. Compose handles the interpolation of position, size, and shape automatically.
kotlin
@Composable
fun AppNavHost(navController: NavHostController) {
    SharedTransitionLayout {
        NavHost(navController, startDestination = "list") {
            composable("list") {
                ProductListScreen(
                    onProductClick = { product ->
                        navController.navigate("detail/${product.id}")
                    },
                    animatedVisibilityScope = this
                )
            }

            composable("detail/{productId}") { backStackEntry ->
                val productId = backStackEntry.arguments
                    ?.getString("productId") ?: return@composable
                ProductDetailScreen(
                    productId = productId,
                    animatedVisibilityScope = this,
                    onBack = { navController.popBackStack() }
                )
            }
        }
    }
}

@Composable
fun SharedTransitionScope.ProductCard(
    product: Product,
    onClick: () -> Unit,
    animatedVisibilityScope: AnimatedVisibilityScope
) {
    Card(
        modifier = Modifier
            .clickable(onClick = onClick)
            .sharedElement(
                state = rememberSharedContentState(
                    key = "product-${product.id}"
                ),
                animatedVisibilityScope = animatedVisibilityScope
            )
    ) {
        AsyncImage(
            model = product.imageUrl,
            contentDescription = product.name,
            modifier = Modifier
                .fillMaxWidth()
                .height(200.dp)
                .sharedElement(
                    state = rememberSharedContentState(
                        key = "image-${product.id}"
                    ),
                    animatedVisibilityScope = animatedVisibilityScope
                )
        )
        Text(
            text = product.name,
            modifier = Modifier
                .padding(16.dp)
                .sharedBounds(
                    sharedContentState = rememberSharedContentState(
                        key = "title-${product.id}"
                    ),
                    animatedVisibilityScope = animatedVisibilityScope
                )
        )
    }
}

Gesture-Driven Animation with Animatable

The most polished apps connect animations directly to user gestures. A swipe-to-dismiss card, a pull-to-refresh indicator, or a draggable bottom sheet should track the user's finger in real time, then snap or fling to a final position when released. `Animatable` combined with `pointerInput` gives you this control. The animation value follows the gesture during drag, then uses `animateTo` with a spring spec to settle after release.
kotlin
@Composable
fun SwipeToDismissCard(
    content: @Composable () -> Unit,
    onDismiss: () -> Unit
) {
    val offsetX = remember { Animatable(0f) }
    val dismissThreshold = 300f

    Box(
        modifier = Modifier
            .offset { IntOffset(offsetX.value.roundToInt(), 0) }
            .pointerInput(Unit) {
                detectHorizontalDragGestures(
                    onDragEnd = {
                        if (abs(offsetX.value) > dismissThreshold) {
                            // Fling off screen
                            CoroutineScope(Dispatchers.Main).launch {
                                offsetX.animateTo(
                                    targetValue = if (offsetX.value > 0)
                                        1000f else -1000f,
                                    animationSpec = tween(200)
                                )
                                onDismiss()
                            }
                        } else {
                            // Snap back
                            CoroutineScope(Dispatchers.Main).launch {
                                offsetX.animateTo(
                                    targetValue = 0f,
                                    animationSpec = spring(
                                        dampingRatio = Spring.DampingRatioMediumBouncy,
                                        stiffness = Spring.StiffnessMedium
                                    )
                                )
                            }
                        }
                    },
                    onHorizontalDrag = { _, dragAmount ->
                        CoroutineScope(Dispatchers.Main).launch {
                            offsetX.snapTo(offsetX.value + dragAmount)
                        }
                    }
                )
            }
            .graphicsLayer {
                // Fade and rotate as card is dragged
                alpha = 1f - (abs(offsetX.value) / 1000f)
                    .coerceIn(0f, 1f)
                rotationZ = (offsetX.value / 50f)
                    .coerceIn(-15f, 15f)
            }
    ) {
        content()
    }
}

Performance: Keeping Animations at 60fps

Compose animations run on the composition/layout/draw pipeline, which means a poorly structured animation can trigger expensive recompositions every frame. Follow these rules to keep animations silky smooth: 1. **Use graphicsLayer for transform animations**: Scale, rotation, alpha, and translation via `Modifier.graphicsLayer` avoid recomposition entirely -- they operate at the draw phase only. 2. **Defer state reads**: Use `Modifier.graphicsLayer { alpha = animatedAlpha.value }` (lambda version) instead of `Modifier.alpha(animatedAlpha.value)` (value version). The lambda version reads the state during draw, not during composition. 3. **Label your animations**: Every `animate*AsState` call should have a `label` parameter. This makes Animation Inspector in Android Studio show meaningful names instead of "FloatAnimation#47." 4. **Avoid animating layout-affecting properties**: Animating `width`, `height`, or `padding` triggers layout recalculation every frame. If possible, animate `scale` and `offset` instead. 5. **Profile with Composition Tracing**: Enable composition tracing in the system trace profiler to see exactly which composables recompose during animation frames. Zero recompositions during steady-state animation is the goal. Google's benchmarks show that correctly implemented Compose animations use less CPU than equivalent View-system animations because the framework can skip the measure-layout passes entirely when only draw-phase properties change.
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

  1. 1Apps with thoughtful motion design score 23% higher on perceived quality -- animation is information, not decoration.
  2. 2Use animate*AsState for property changes, AnimatedVisibility for enter/exit, Animatable for gesture-driven motion.
  3. 3Shared element transitions require SharedTransitionLayout wrapping NavHost and sharedElement modifiers on matching elements.
  4. 4Physics-based springs (DampingRatioMediumBouncy, StiffnessLow) feel more natural than linear or easing-based specs.
  5. 5Always use Modifier.graphicsLayer for transform animations to avoid recomposition and stay at 60fps.
  6. 6Label every animation for meaningful names in Android Studio's Animation Inspector.
MOD · FAQ2 ENTRIESANSWERED

Frequently Asked

How do I avoid animation performance issues?

Avoid animating large composition trees. Use graphicsLayer for complex animations. Profile with Layout Inspector. Use LaunchedEffect for animation coordination.

What's the best animation spec?

Spring for natural, responsive motion. Tween for precise, choreographed sequences. Use springDefaults for common patterns. Customize stiffness and damping for brand feel.

MOD · BUILD · NEXTS/N · AX-CTA-0001READY

Ready to architect your next Android app?

ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.

ANDROID-ARCHITECT · CONSOLE
S/N · AX-1A-00001
STRATEGIA-X · ENGINEERED · IN · CALIFORNIA