Droid Manual // Android Knowledge Base

Droid Manual

Compose Animations Tutorial

October 31, 2026 · 9 min read

Introduction

Jetpack Compose brings a declarative approach to animations that is fundamentally different from the imperative View system. Instead of manually triggering property changes and interpolating values, you describe what the UI should look like at each state, and Compose handles the transitions for you. This paradigm shift reduces boilerplate, prevents animation glitches, and makes your code far more readable. In this tutorial, we will walk through the core animation APIs you need to build polished, production-ready interfaces — from simple state-driven property animations to gesture-driven motion and spring-based interactions.

animateDpAsState: Property-Level Animations

The simplest and most commonly used animation API in Compose is animateDpAsState. It animates a Dp value between two states automatically whenever the input changes. Under the hood, Compose creates an Animatable that interpolates from the old value to the new value using a default spring specification.

@Composable
fun AnimateDpExample() {
    var expanded by remember { mutableStateOf(false) }

    val cardWidth by animateDpAsState(
        targetValue = if (expanded) 280.dp else 160.dp,
        label = "card_width_animation"
    )

    Column(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Card(
            modifier = Modifier
                .width(cardWidth)
                .height(100.dp)
                .clickable { expanded = !expanded },
            shape = RoundedCornerShape(12.dp)
        ) {
            Box(
                modifier = Modifier.fillMaxSize(),
                contentAlignment = Alignment.Center
            ) {
                Text(text = if (expanded) "Expanded Card" else "Tap Me")
            }
        }
        Spacer(modifier = Modifier.height(16.dp))
        Text(text = "Card width: ${cardWidth.value.toInt()}dp")
    }
}

This example demonstrates how a single state variable drives a smooth width transition on a Card. When the user taps the card, expanded flips and animateDpAsState automatically animates cardWidth to the new target. The label parameter is important for Android Studio's Layout Inspector, where you can visually trace which animation is running and inspect its current value in real time. Without the label, debugging becomes significantly harder in complex screens.

AnimatedVisibility: Controlling Entry and Exit

AnimatedVisibility is the go-to composable for animating the appearance and disappearance of entire groups of composables. It accepts separate enter and exit FadeIn, FadeOut, ExpandVertically, ShrinkVertically, ExpandHorizontally, and ShrinkHorizontally specifications, which you can freely combine. This makes it ideal for list items, dialog sheets, and conditional panels.

@Composable
fun AnimatedVisibilityExample() {
    var isVisible by remember { mutableStateOf(false) }

    Column(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Button(onClick = { isVisible = !isVisible }) {
            Text(text = if (isVisible) "Hide Panel" else "Show Panel")
        }

        Spacer(modifier = Modifier.height(16.dp))

        AnimatedVisibility(
            visible = isVisible,
            enter = expandVertically(
                animationSpec = spring(stiffness = Spring.StiffnessMedium)
            ) + fadeIn(
                animationSpec = spring(stiffness = Spring.StiffnessLow)
            ),
            exit = shrinkVertically(
                animationSpec = spring(stiffness = Spring.StiffnessHigh)
            ) + fadeOut(
                animationSpec = spring(stiffness = Spring.StiffnessHigh)
            )
        ) {
            Surface(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(top = 8.dp),
                color = MaterialTheme.colorScheme.primaryContainer,
                shape = RoundedCornerShape(8.dp)
            ) {
                Column(
                    modifier = Modifier.padding(16.dp)
                ) {
                    Text(text = "Panel Content", style = MaterialTheme.typography.titleMedium)
                    Spacer(modifier = Modifier.height(8.dp))
                    Text(
                        text = "This panel slides in from the bottom with a staggered fade effect.",
                        style = MaterialTheme.typography.bodyMedium
                    )
                }
            }
        }
    }
}

Here the enter animation combines a vertical expansion with a fade-in, while the exit reverses the motion with a quicker spring. The stiffness values control how snappy the spring feels: a higher stiffness produces faster, more rigid motion, while lower stiffness yields a looser, more elastic feel. Notice that both the enter and exit are compound animations — the + operator merges them into a single transition, and Compose plays them simultaneously. This is far cleaner than managing multiple animated properties by hand.

AnimatedContent: State-Driven Content Transitions

While AnimatedVisibility animates a block appearing or disappearing, AnimatedContent is designed for transitioning between different content states — for example, swapping one composable tree for another based on a state value. It uses key-based matching to reuse animated composables across transitions, which eliminates jarring layout jumps.

@Composable
fun AnimatedContentExample() {
    enum class Screen { Home, Settings }
    var currentScreen by remember { mutableStateOf(Screen.Home) }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    ) {
        Row(
            horizontalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            Button(
                onClick = { currentScreen = Screen.Home },
                enabled = currentScreen != Screen.Home
            ) {
                Text("Home")
            }
            Button(
                onClick = { currentScreen = Screen.Settings },
                enabled = currentScreen != Screen.Settings
            ) {
                Text("Settings")
            }
        }

        Spacer(modifier = Modifier.height(24.dp))

        AnimatedContent(
            targetState = currentScreen,
            transitionSpec = {
                slideIntoContainer(
                    towards = AnimatedContentScope.TargetSide.Start,
                    animationSpec = spring(stiffness = Spring.StiffnessLow)
                ) with slideOutOfContainer(
                    towards = AnimatedContentScope.TargetSide.End,
                    animationSpec = spring(stiffness = Spring.StiffnessLow)
                )
            },
            content = { screen ->
                when (screen) {
                    Screen.Home -> {
                        Surface(
                            modifier = Modifier
                                .fillMaxWidth()
                                .height(120.dp),
                            color = MaterialTheme.colorScheme.primaryContainer,
                            shape = RoundedCornerShape(12.dp)
                        ) {
                            Box(
                                modifier = Modifier.fillMaxSize(),
                                contentAlignment = Alignment.Center
                            ) {
                                Text("🏠 Home Screen", style = MaterialTheme.typography.headlineSmall)
                            }
                        }
                    }
                    Screen.Settings -> {
                        Surface(
                            modifier = Modifier
                                .fillMaxWidth()
                                .height(120.dp),
                            color = MaterialTheme.colorScheme.secondaryContainer,
                            shape = RoundedCornerShape(12.dp)
                        ) {
                            Box(
                                modifier = Modifier.fillMaxSize(),
                                contentAlignment = Alignment.Center
                            ) {
                                Text("⚙️ Settings Screen", style = MaterialTheme.typography.headlineSmall)
                            }
                        }
                    }
                }
            }
        )
    }
}

The transitionSpec lambda gives you full control over how the target and current content move during the swap. In this case, the new screen slides in from the left while the old one slides out to the right, producing a navigation-like experience. The targetState parameter drives the entire transition — every time it changes, Compose recomputes which branch of the content lambda to render and applies the specified transition between the old and new trees.

Transition Specs and Spring Physics

Compose's animation system is built around AnimationSpec, and the most flexible option is spring(). Springs model physical elasticity: they define stiffness, damping ratio, and rest speed, which together produce motion that feels natural and responsive. You can also use tween() for time-based easing animations, but springs are generally preferred for user-interaction-driven motion because they react appropriately to velocity and stopping distance.

@Composable
fun SpringPhysicsExample() {
    var rotationDegrees by remember { mutableStateOf(0f) }
    var scale by remember { mutableStateOf(1f) }

    val rotationAnim by animateFloatAsState(
        targetValue = rotationDegrees,
        animationSpec = spring(
            stiffness = Spring.StiffnessVeryLow,
            dampingRatio = Spring.DampingRatioMediumBouncy
        ),
        label = "rotation_animation"
    )

    val scaleAnim by animateFloatAsState(
        targetValue = scale,
        animationSpec = spring(
            stiffness = Spring.StiffnessMedium,
            dampingRatio = Spring.DampingRatioHigh
        ),
        label = "scale_animation"
    )

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(32.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(32.dp, Alignment.CenterVertically)
    ) {
        Box(
            modifier = Modifier
                .size(120.dp)
                .rotate(rotationAnim)
                .scale(scaleAnim)
                .clip(RoundedCornerShape(16.dp))
                .background(MaterialTheme.colorScheme.primary)
                .indication(indication = null, interactionSource = remember { InteractionSource() })
                .clickable {
                    rotationDegrees = if (rotationDegrees == 0f) 360f else 0f
                    scale = if (scale == 1f) 1.4f else 1f
                },
            contentAlignment = Alignment.Center
        ) {
            Text(text = "🎯", fontSize = 40.sp)
        }

        Column(horizontalAlignment = Alignment.CenterHorizontally) {
            Text(
                text = "Rotation: ${"%.0f".format(rotationAnim)}°",
                style = MaterialTheme.typography.bodyMedium
            )
            Text(
                text = "Scale: ${"%.2f".format(scaleAnim)}",
                style = MaterialTheme.typography.bodyMedium
            )
        }
    }
}

This example showcases two independent springs acting on different properties of the same box. The rotation uses a low-stiffness, medium-bouncy spring so the box overshoots and settles with a playful wobble, while the scale uses a high-damping spring for a quicker, more controlled snap. Each call to animateFloatAsState creates an independent animation scope — changing rotationDegrees does not affect the scale animation, and vice versa. The label parameters are again essential for debugging; they appear in the Layout Inspector as distinct animation tracks you can inspect per-property.

Gesture-Driven Animations

Gesture-driven animations respond directly to user touch input, making them essential for pull-to-refresh, swipe-to-dismiss, drag-to-reorder, and custom carousel components. Compose provides dragable modifiers and Modifier.pointerInput to capture gestures, and you pair them with Animatable for smooth, frame-rate-independent interpolation.

@Composable
fun GestureAnimationExample() {
    val translationX = remember { Animatable(0f) }
    var isFlipped by remember { mutableStateOf(false) }

    LaunchedEffect(isFlipped) {
        if (isFlipped) {
            translationX.animateTo(targetValue = 200f, animationSpec = spring(stiffness = Spring.StiffnessMedium))
        } else {
            translationX.animateTo(targetValue = 0f, animationSpec = spring(stiffness = Spring.StiffnessMedium))
        }
    }

    Box(
        modifier = Modifier
            .fillMaxSize()
            .padding(32.dp),
        contentAlignment = Alignment.Center
    ) {
        Box(
            modifier = Modifier
                .size(120.dp)
                .offset{x = translationX.value.dp}
                .background(
                    color = if (isFlipped) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
                    shape = RoundedCornerShape(16.dp)
                )
                .pointerInput(Unit) {
                    detectHorizontalDragGestune(
                        onDragEnd = {
                            isFlipped = !isFlipped
                        }
                    )
                },
            contentAlignment = Alignment.Center
        ) {
            Text(
                text = if (isFlipped) "Flipped!" else "Swipe Me",
                color = Color.White,
                style = MaterialTheme.typography.titleMedium
            )
        }

        if (translationX.value > 100f) {
            Text(
                modifier = Modifier
                    .align(Alignment.CenterStart)
                    .offset{x = (-translationX.value + 40).dp},
                text = "Undo",
                style = MaterialTheme.typography.bodySmall,
                color = MaterialTheme.colorScheme.onSurfaceVariant
            )
        }
    }
}

This gesture-driven card uses offset to visually follow the drag while Animatable provides the smoothing layer. When the user releases the drag, onDragEnd flips the card state, and LaunchedEffect reacts to that state change by animating the card back to its starting position or swinging it forward. The detectHorizontalDragGestune API captures the raw gesture, and the offset modifier applies the interpolated position each frame. This separation of gesture detection from animation state is the recommended pattern — it keeps gesture logic testable and animation logic decoupled.

Common Pitfalls

One of the most frequent mistakes is calling animation state inside a recomposition without proper keying. If you place animateDpAsState or animateFloatAsState inside a loop or a composable that recomposes frequently without stabilizing its inputs, Compose will restart the animation on every recomposition, causing jarring jumps. Always wrap animation state in remember with appropriate keys to prevent unnecessary resets.

Another common issue is forgetting that AnimatedVisibility and AnimatedContent only animate their children, not the container itself. If your parent layout changes size abruptly while the child is animating in, you will see layout thrashing. Use Modifier.wrapContentSize(unbounded = true) or constrain the parent size to avoid this. Additionally, never use MutableState fields directly as animation targets without by animateXxxAsState — reading state directly in a modifier does not trigger recomposition for the animation curve.

Finally, be mindful of the label parameter in every animation API. In production apps, omitting labels makes it nearly impossible to diagnose animation performance issues in the Layout Inspector or Android Profiler. Every animation spec you write should have a descriptive label that identifies both the animated property and the component it belongs to.

Conclusion

Compose's animation APIs form a cohesive, declarative system that replaces the verbose, imperative animation code of the legacy View framework. animateDpAsState and the broader family of animateXxxAsState functions handle simple property transitions with minimal boilerplate. AnimatedVisibility and AnimatedContent manage group-level entry and exit transitions with customizable enter and exit specs. Spring physics give your animations a natural, physically grounded feel, and gesture-driven patterns let you build rich, interactive components. The key to mastering Compose animations is understanding the relationship between state, recomposition, and animation scope — when these align correctly, your interfaces feel responsive and polished with remarkably little code.