Lesson 74 of 83 advanced

Compose Animations: Transition, AnimatedVisibility & Motion

Create fluid, production-quality animations in Jetpack Compose

Open interactive version (quiz + challenge)

Real-world analogy

Compose animations are like a movie director's toolkit. animate*AsState is the simple camera pan — point A to point B, done. AnimatedVisibility is the stage curtain rising and falling. updateTransition is orchestrating multiple actors moving in sync during a scene change. And InfiniteTransition is the looping background music that never stops. MotionLayout is your stunt coordinator — handling complex, choreographed sequences that would be impossible to direct manually.

What is it?

Compose Animations is a comprehensive animation system built into Jetpack Compose that provides declarative, state-driven animation APIs. From simple value animations (animate*AsState) to complex choreographed transitions (updateTransition, MotionLayout), it handles enter/exit animations (AnimatedVisibility), content swaps (AnimatedContent), infinite loops (InfiniteTransition), and gesture-driven motion (Animatable), all designed to be interruptible, composable, and performant.

Real-world relevance

Every polished app uses animations extensively. WhatsApp uses AnimatedVisibility-style animations for message reactions appearing. Instagram uses gesture-driven animations for story swiping and double-tap hearts. Twitter/X uses AnimatedContent for the like counter incrementing. Spotify uses InfiniteTransition for its equalizer bars. Google Maps uses MotionLayout-style coordinated transitions for its bottom sheet expanding into full screen.

Key points

Code example

// === animate*AsState basics ===
@Composable
fun ExpandableCard(title: String, content: String) {
    var expanded by remember { mutableStateOf(false) }

    val elevation by animateDpAsState(
        targetValue = if (expanded) 8.dp else 2.dp,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness = Spring.StiffnessLow
        ),
        label = "cardElevation"
    )

    val backgroundColor by animateColorAsState(
        targetValue = if (expanded)
            MaterialTheme.colorScheme.primaryContainer
        else
            MaterialTheme.colorScheme.surface,
        animationSpec = tween(300),
        label = "cardColor"
    )

    Card(
        modifier = Modifier
            .fillMaxWidth()
            .clickable { expanded = !expanded },
        elevation = CardDefaults.cardElevation(elevation),
        colors = CardDefaults.cardColors(containerColor = backgroundColor)
    ) {
        Column(modifier = Modifier.padding(16.dp)) {
            Text(title, style = MaterialTheme.typography.titleMedium)

            AnimatedVisibility(
                visible = expanded,
                enter = fadeIn(tween(300)) +
                    expandVertically(
                        animationSpec = spring(
                            stiffness = Spring.StiffnessLow
                        )
                    ),
                exit = fadeOut(tween(200)) +
                    shrinkVertically()
            ) {
                Text(
                    text = content,
                    modifier = Modifier.padding(top = 8.dp)
                )
            }
        }
    }
}

// === AnimatedContent with number counter ===
@Composable
fun AnimatedCounter(count: Int) {
    AnimatedContent(
        targetState = count,
        transitionSpec = {
            if (targetState > initialState) {
                slideInVertically { height -> height } +
                    fadeIn() togetherWith
                    slideOutVertically { height -> -height } +
                    fadeOut()
            } else {
                slideInVertically { height -> -height } +
                    fadeIn() togetherWith
                    slideOutVertically { height -> height } +
                    fadeOut()
            }.using(SizeTransform(clip = false))
        },
        label = "counter"
    ) { targetCount ->
        Text(
            text = "$targetCount",
            style = MaterialTheme.typography.displayLarge
        )
    }
}

// === updateTransition for coordinated animation ===
enum class BoxState { Collapsed, Expanded }

@Composable
fun TransitionBox() {
    var currentState by remember {
        mutableStateOf(BoxState.Collapsed)
    }
    val transition = updateTransition(
        targetState = currentState,
        label = "boxTransition"
    )

    val size by transition.animateDp(
        transitionSpec = {
            if (targetState == BoxState.Expanded) {
                spring(stiffness = Spring.StiffnessLow)
            } else {
                spring(stiffness = Spring.StiffnessMedium)
            }
        },
        label = "size"
    ) { state ->
        when (state) {
            BoxState.Collapsed -> 64.dp
            BoxState.Expanded -> 200.dp
        }
    }

    val cornerRadius by transition.animateDp(
        label = "corner"
    ) { state ->
        when (state) {
            BoxState.Collapsed -> 32.dp
            BoxState.Expanded -> 16.dp
        }
    }

    val color by transition.animateColor(
        label = "color"
    ) { state ->
        when (state) {
            BoxState.Collapsed -> Color(0xFF6200EE)
            BoxState.Expanded -> Color(0xFF03DAC5)
        }
    }

    Box(
        modifier = Modifier
            .size(size)
            .clip(RoundedCornerShape(cornerRadius))
            .background(color)
            .clickable {
                currentState = if (currentState == BoxState.Collapsed)
                    BoxState.Expanded else BoxState.Collapsed
            }
    )
}

// === InfiniteTransition for loading ===
@Composable
fun PulsingDot() {
    val infiniteTransition = rememberInfiniteTransition(
        label = "pulse"
    )

    val scale by infiniteTransition.animateFloat(
        initialValue = 0.8f,
        targetValue = 1.2f,
        animationSpec = infiniteRepeatable(
            animation = tween(
                durationMillis = 600,
                easing = FastOutSlowInEasing
            ),
            repeatMode = RepeatMode.Reverse
        ),
        label = "scale"
    )

    val alpha by infiniteTransition.animateFloat(
        initialValue = 0.4f,
        targetValue = 1f,
        animationSpec = infiniteRepeatable(
            animation = tween(600),
            repeatMode = RepeatMode.Reverse
        ),
        label = "alpha"
    )

    Box(
        modifier = Modifier
            .size(24.dp)
            .graphicsLayer {
                scaleX = scale
                scaleY = scale
                this.alpha = alpha
            }
            .clip(CircleShape)
            .background(MaterialTheme.colorScheme.primary)
    )
}

// === Gesture-driven animation with Animatable ===
@Composable
fun SwipeToDismissCard(
    onDismiss: () -> Unit,
    content: @Composable () -> Unit
) {
    val offsetX = remember { Animatable(0f) }
    val scope = rememberCoroutineScope()

    Box(
        modifier = Modifier
            .offset {
                IntOffset(offsetX.value.roundToInt(), 0)
            }
            .pointerInput(Unit) {
                detectHorizontalDragGestures(
                    onDragEnd = {
                        scope.launch {
                            if (abs(offsetX.value) > size.width / 3) {
                                val target = if (offsetX.value > 0)
                                    size.width.toFloat()
                                else
                                    -size.width.toFloat()
                                offsetX.animateTo(
                                    targetValue = target,
                                    animationSpec = tween(300)
                                )
                                onDismiss()
                            } else {
                                offsetX.animateTo(
                                    targetValue = 0f,
                                    animationSpec = spring(
                                        dampingRatio = Spring
                                            .DampingRatioMediumBouncy
                                    )
                                )
                            }
                        }
                    }
                ) { _, dragAmount ->
                    scope.launch {
                        offsetX.snapTo(
                            offsetX.value + dragAmount
                        )
                    }
                }
            }
    ) {
        content()
    }
}

Line-by-line walkthrough

  1. 1. ExpandableCard uses animateDpAsState for elevation and animateColorAsState for background — both animate automatically when 'expanded' state changes.
  2. 2. The spring spec with DampingRatioMediumBouncy creates a playful bounce effect on the elevation change.
  3. 3. AnimatedVisibility wraps the content text — fadeIn + expandVertically creates a smooth reveal from top.
  4. 4. AnimatedCounter uses AnimatedContent where the transitionSpec checks direction (up vs down) to slide numbers appropriately.
  5. 5. slideInVertically { height -> height } slides in from the bottom (positive = below viewport), while { -height } slides from top.
  6. 6. togetherWith combines enter and exit into a ContentTransform. SizeTransform(clip = false) allows content to overflow during transition.
  7. 7. TransitionBox uses updateTransition to coordinate size, cornerRadius, and color — all animate in sync when BoxState changes.
  8. 8. Each transition.animate* call can have its own transitionSpec, allowing different physics per property while staying synchronized.
  9. 9. PulsingDot uses rememberInfiniteTransition with RepeatMode.Reverse for smooth scale and alpha oscillation.
  10. 10. graphicsLayer is used for scale and alpha — these are render-only changes that don't trigger recomposition.
  11. 11. SwipeToDismissCard uses Animatable for offsetX, enabling snapTo() during drag and animateTo() on release.
  12. 12. The drag threshold (size.width / 3) determines whether to dismiss or spring back — spring creates a natural bounce-back feel.

Spot the bug

@Composable
fun FadeToggle(visible: Boolean, content: @Composable () -> Unit) {
    val alpha by animateFloatAsState(
        targetValue = if (visible) 1f else 0f,
        animationSpec = tween(500),
        label = "alpha"
    )

    Box(modifier = Modifier.alpha(alpha)) {
        content()
    }
}
Need a hint?
When alpha reaches 0, is the content actually removed from the composition? What about click handlers and accessibility?
Show answer
The content is still composed and in the layout tree even when alpha = 0. It remains clickable and accessible to screen readers. Use AnimatedVisibility instead, which actually removes content from the composition tree when the exit animation completes. If you must use alpha, add `.then(if (alpha == 0f) Modifier.size(0.dp) else Modifier)` or better yet use `AnimatedVisibility(visible = visible, enter = fadeIn(tween(500)), exit = fadeOut(tween(500))) { content() }`.

Explain like I'm 5

Imagine you have a magic coloring book. When you say 'make the box big,' it doesn't just jump to being big — it smoothly grows like a balloon being inflated (that's animate*AsState). When you say 'show the hidden picture,' it fades in like a ghost appearing (AnimatedVisibility). When you say 'change the picture,' the old one slides away and the new one slides in like a sliding door (AnimatedContent). And some pictures keep wiggling forever like a waving flag (InfiniteTransition). The magic book always makes changes look smooth and pretty!

Fun fact

The Compose animation system was designed by the same team that built Android's original Property Animation framework (ObjectAnimator), but they took the opposite approach. Instead of imperatively starting animations, Compose animations are declarative — you describe the target state, and the framework figures out how to get there. The spring() animation is the default because physics-based animations feel more natural and handle interruptions gracefully, unlike duration-based animations that can feel jarring when redirected mid-flight.

Hands-on challenge

Build an animated task management card that demonstrates multiple animation techniques: (1) The card uses AnimatedVisibility to reveal a description and action buttons when tapped, (2) A priority indicator dot pulses using InfiniteTransition when the task is high priority, (3) Implement swipe-to-complete using Animatable with gesture detection that reveals a green checkmark underneath, (4) When the task status changes (todo -> in-progress -> done), use AnimatedContent with a sliding ContentTransform to swap the status badge, (5) Add updateTransition to coordinate the background color, border width, and icon rotation when toggling a 'starred' state. Ensure all animations use graphicsLayer where appropriate for performance.

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery