Your app works. But it feels flat. Buttons appear instantly. Screens switch without transition. Content pops in and out like a slideshow from 2005.

Animations fix that. They make your app feel smooth, polished, and alive — like a well-made product instead of a homework assignment.

The good news: Compose makes animations surprisingly easy. You can add most animations with a single line of code.

The Animation API at a Glance

Compose has several animation APIs, each for a different use case:

APIWhat It DoesWhen to Use
AnimatedVisibilityShow/hide with animationAppearing/disappearing elements
animateAsStateAnimate a single valueColor, size, offset changes
AnimatedContentAnimate between different contentSwitching text, icons, layouts
CrossfadeFade between two layoutsSimple content switching
animateContentSizeAuto-animate size changesExpanding/collapsing cards
Modifier.animateItemPlacement()Animate list item reorderLazyColumn reorder

Let’s learn each one.

AnimatedVisibility — Show and Hide with Style

The most commonly used animation. Wrap any Composable with AnimatedVisibility and it animates in and out:

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

    Column {
        Button(onClick = { isVisible = !isVisible }) {
            Text(if (isVisible) "Hide" else "Show")
        }

        AnimatedVisibility(visible = isVisible) {
            Text(
                "Hello! I fade in and out smoothly.",
                modifier = Modifier
                    .fillMaxWidth()
                    .background(MaterialTheme.colorScheme.primaryContainer)
                    .padding(16.dp)
            )
        }
    }
}

By default, AnimatedVisibility uses fade + expand. You can customize it:

Custom Enter and Exit Animations

AnimatedVisibility(
    visible = isVisible,
    enter = slideInVertically() + fadeIn(),
    exit = slideOutVertically() + fadeOut()
) {
    Text("I slide in from the top and fade in")
}

Available enter animations:

  • fadeIn() — fade from transparent
  • slideInVertically() — slide from top or bottom
  • slideInHorizontally() — slide from left or right
  • expandVertically() — expand from zero height
  • expandHorizontally() — expand from zero width
  • scaleIn() — grow from small to full size

You can combine them with +:

// Slide from bottom + fade in at the same time
enter = slideInVertically { it } + fadeIn()

// Scale down + fade out at the same time
exit = scaleOut() + fadeOut()

Practical Example: Expandable Card

@Composable
fun ExpandableCard(title: String, content: String) {
    var isExpanded by remember { mutableStateOf(false) }

    Surface(
        modifier = Modifier
            .fillMaxWidth()
            .clickable { isExpanded = !isExpanded },
        shape = RoundedCornerShape(12.dp),
        color = MaterialTheme.colorScheme.surfaceVariant
    ) {
        Column(modifier = Modifier.padding(16.dp)) {
            Row(verticalAlignment = Alignment.CenterVertically) {
                Text(
                    title,
                    style = MaterialTheme.typography.titleMedium,
                    modifier = Modifier.weight(1f)
                )
                Icon(
                    imageVector = if (isExpanded) Icons.Default.KeyboardArrowUp
                    else Icons.Default.KeyboardArrowDown,
                    contentDescription = if (isExpanded) "Collapse" else "Expand"
                )
            }

            AnimatedVisibility(
                visible = isExpanded,
                enter = expandVertically() + fadeIn(),
                exit = shrinkVertically() + fadeOut()
            ) {
                Text(
                    content,
                    modifier = Modifier.padding(top = 8.dp),
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
            }
        }
    }
}

Tap the card → content expands smoothly. Tap again → it collapses. One AnimatedVisibility does all the work.

animateAsState — Animate Single Values

When you want to animate a color, size, position, or any single value:

@Composable
fun AnimatedButton() {
    var isSelected by remember { mutableStateOf(false) }

    // These values animate smoothly when isSelected changes
    val backgroundColor by animateColorAsState(
        targetValue = if (isSelected) MaterialTheme.colorScheme.primary
        else MaterialTheme.colorScheme.surfaceVariant,
        label = "backgroundColor"
    )

    val cornerRadius by animateDpAsState(
        targetValue = if (isSelected) 24.dp else 8.dp,
        label = "cornerRadius"
    )

    val textColor by animateColorAsState(
        targetValue = if (isSelected) MaterialTheme.colorScheme.onPrimary
        else MaterialTheme.colorScheme.onSurfaceVariant,
        label = "textColor"
    )

    Surface(
        modifier = Modifier
            .fillMaxWidth()
            .clickable { isSelected = !isSelected },
        shape = RoundedCornerShape(cornerRadius),
        color = backgroundColor
    ) {
        Text(
            text = if (isSelected) "Selected" else "Tap to Select",
            color = textColor,
            modifier = Modifier.padding(16.dp),
            fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
        )
    }
}

When isSelected changes, all three values animate simultaneously: the background color, corner radius, and text color smoothly transition to their new values.

Available animate*AsState Functions

FunctionAnimates
animateColorAsStateColors
animateDpAsStateDp values (size, padding, offset)
animateFloatAsStateFloat values (alpha, scale, rotation)
animateIntAsStateInteger values
animateOffsetAsStateX/Y positions
animateSizeAsStateWidth/height pairs

Custom Animation Speed

val size by animateDpAsState(
    targetValue = if (isExpanded) 200.dp else 50.dp,
    animationSpec = tween(
        durationMillis = 300,  // 300ms duration
        easing = FastOutSlowInEasing  // Smooth curve
    ),
    label = "size"
)

Common animation specs:

  • tween(durationMillis = 300) — fixed duration animation
  • spring(dampingRatio = 0.7f) — bouncy spring animation
  • snap() — instant, no animation
  • repeatable(iterations = 3, animation = tween(200)) — repeat N times
  • infiniteRepeatable(animation = tween(1000)) — loop forever

animateContentSize — Auto-Animate Size Changes

The easiest animation. Just add the modifier — Compose handles the rest:

@Composable
fun ExpandableText() {
    var isExpanded by remember { mutableStateOf(false) }

    Text(
        text = "This is a long text that demonstrates animateContentSize. " +
            "When you tap it, it expands to show the full content. " +
            "The size change is animated automatically. " +
            "No manual animation code needed at all. Just the modifier.",
        maxLines = if (isExpanded) Int.MAX_VALUE else 2,
        overflow = TextOverflow.Ellipsis,
        modifier = Modifier
            .fillMaxWidth()
            .animateContentSize(  // This one modifier does everything
                animationSpec = spring(dampingRatio = Spring.DampingRatioLowBouncy)
            )
            .clickable { isExpanded = !isExpanded }
            .padding(16.dp)
    )
}

animateContentSize() measures the content before and after the change, then smoothly animates between the two sizes. Perfect for expanding cards, growing text, and resizable containers.

AnimatedContent — Animate Between Different Content

When you want to switch between different Composables with animation:

@Composable
fun AnimatedCounter() {
    var count by remember { mutableStateOf(0) }

    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        AnimatedContent(
            targetState = count,
            transitionSpec = {
                // New number slides in from bottom, old slides out to top
                slideInVertically { it } + fadeIn() togetherWith
                    slideOutVertically { -it } + fadeOut()
            },
            label = "counter"
        ) { targetCount ->
            Text(
                text = "$targetCount",
                fontSize = 64.sp,
                fontWeight = FontWeight.Bold,
                color = MaterialTheme.colorScheme.primary
            )
        }

        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            Button(onClick = { count-- }) { Text("-") }
            Button(onClick = { count++ }) { Text("+") }
        }
    }
}

Each time count changes, the old number slides out and the new number slides in. This looks much better than just changing the text instantly.

AnimatedContent for Switching Views

@Composable
fun TabContent(selectedTab: Int) {
    AnimatedContent(
        targetState = selectedTab,
        transitionSpec = {
            // Slide direction based on which tab was selected
            if (targetState > initialState) {
                slideInHorizontally { it } + fadeIn() togetherWith
                    slideOutHorizontally { -it } + fadeOut()
            } else {
                slideInHorizontally { -it } + fadeIn() togetherWith
                    slideOutHorizontally { it } + fadeOut()
            }
        },
        label = "tabContent"
    ) { tab ->
        when (tab) {
            0 -> HomeContent()
            1 -> SearchContent()
            2 -> ProfileContent()
        }
    }
}

The content slides left or right depending on whether you go to a higher or lower tab index.

Crossfade — Simple Content Switching

Crossfade is a simpler version of AnimatedContent — it just fades between two layouts:

@Composable
fun LoadingScreen(isLoading: Boolean) {
    Crossfade(
        targetState = isLoading,
        animationSpec = tween(300),
        label = "loading"
    ) { loading ->
        if (loading) {
            Box(
                modifier = Modifier.fillMaxSize(),
                contentAlignment = Alignment.Center
            ) {
                CircularProgressIndicator()
            }
        } else {
            // Your actual content
            Text("Data loaded!")
        }
    }
}

Use Crossfade when you just need a fade transition. Use AnimatedContent when you need slide, scale, or custom transitions.

Practical Example: Animated Settings Screen

Let’s combine multiple animations in one real screen:

@Composable
fun AnimatedSettingsScreen() {
    var darkMode by remember { mutableStateOf(false) }
    var notifications by remember { mutableStateOf(true) }
    var fontSize by remember { mutableStateOf(16f) }
    var showAdvanced by remember { mutableStateOf(false) }

    // Animated background color
    val backgroundColor by animateColorAsState(
        targetValue = if (darkMode) Color(0xFF121212) else Color.White,
        animationSpec = tween(500),
        label = "bg"
    )

    Column(
        modifier = Modifier
            .fillMaxSize()
            .background(backgroundColor)
            .padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(16.dp)
    ) {
        Text("Settings", style = MaterialTheme.typography.headlineLarge)

        // Dark mode toggle with animated icon
        Row(
            modifier = Modifier.fillMaxWidth(),
            verticalAlignment = Alignment.CenterVertically
        ) {
            AnimatedContent(
                targetState = darkMode,
                label = "icon"
            ) { isDark ->
                Icon(
                    imageVector = if (isDark) Icons.Default.DarkMode
                    else Icons.Default.LightMode,
                    contentDescription = null,
                    modifier = Modifier.size(24.dp)
                )
            }

            Spacer(modifier = Modifier.width(12.dp))
            Text("Dark Mode", modifier = Modifier.weight(1f))
            Switch(checked = darkMode, onCheckedChange = { darkMode = it })
        }

        // Notifications toggle
        Row(
            modifier = Modifier.fillMaxWidth(),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Text("Notifications", modifier = Modifier.weight(1f))
            Switch(checked = notifications, onCheckedChange = { notifications = it })
        }

        // Font size with animated preview
        Text("Font Size: ${fontSize.toInt()}sp")
        Slider(
            value = fontSize,
            onValueChange = { fontSize = it },
            valueRange = 12f..32f
        )

        // Animated font preview
        val animatedFontSize by animateFloatAsState(
            targetValue = fontSize,
            animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
            label = "fontSize"
        )
        Text(
            "Preview Text",
            fontSize = animatedFontSize.sp,
            fontWeight = FontWeight.Medium
        )

        // Show/hide advanced settings
        OutlinedButton(onClick = { showAdvanced = !showAdvanced }) {
            Text(if (showAdvanced) "Hide Advanced" else "Show Advanced")
        }

        AnimatedVisibility(
            visible = showAdvanced,
            enter = expandVertically() + fadeIn(),
            exit = shrinkVertically() + fadeOut()
        ) {
            Column(
                modifier = Modifier
                    .fillMaxWidth()
                    .background(
                        MaterialTheme.colorScheme.surfaceVariant,
                        RoundedCornerShape(12.dp)
                    )
                    .padding(16.dp),
                verticalArrangement = Arrangement.spacedBy(8.dp)
            ) {
                Text("Advanced Settings", fontWeight = FontWeight.Bold)
                Text("Cache size: 256 MB")
                Text("Debug mode: Off")
                Text("API endpoint: production")
            }
        }
    }
}

This screen has:

  • Animated background color when dark mode toggles
  • AnimatedContent for the dark/light mode icon
  • Animated font size preview with spring physics
  • AnimatedVisibility for the advanced settings section

Common Mistakes

Mistake 1: Animating Without a Key Change

// BAD — animation never triggers because nothing changes
val color by animateColorAsState(
    targetValue = Color.Red, // Always red — no animation
    label = "color"
)

// GOOD — animation triggers when isError changes
val color by animateColorAsState(
    targetValue = if (isError) Color.Red else Color.Green,
    label = "color"
)

Mistake 2: Missing the label Parameter

// BAD — compiler warning, harder to debug
val size by animateDpAsState(targetValue = 100.dp)

// GOOD — label helps identify animations in debugging tools
val size by animateDpAsState(targetValue = 100.dp, label = "cardSize")

Mistake 3: Over-Animating

Not everything needs animation. Animate meaningful state changes (expand/collapse, show/hide, selection). Don’t animate every color change and padding adjustment — it makes the UI feel sluggish.

Mistake 4: Using infiniteRepeatable Without Stopping

// BAD — animates forever, wastes battery
val alpha by animateFloatAsState(
    targetValue = 1f,
    animationSpec = infiniteRepeatable(tween(1000)),
    label = "alpha"
)

// GOOD — use InfiniteTransition for intentional infinite animations
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
val alpha by infiniteTransition.animateFloat(
    initialValue = 0.3f,
    targetValue = 1f,
    animationSpec = infiniteRepeatable(
        animation = tween(1000),
        repeatMode = RepeatMode.Reverse
    ),
    label = "pulseAlpha"
)

Quick Reference

What You WantAPI to Use
Show/hide an elementAnimatedVisibility(visible = show) { content }
Animate a coloranimateColorAsState(if (x) Red else Blue)
Animate a sizeanimateDpAsState(if (x) 200.dp else 50.dp)
Auto-animate size changesModifier.animateContentSize()
Switch between contentAnimatedContent(targetState = x) { }
Fade between contentCrossfade(targetState = x) { }
Animate list item reorderModifier.animateItemPlacement()

Result

Here is what the app looks like when you run the code from this tutorial:

Light ModeDark Mode
Tutorial 15 LightTutorial 15 Dark

Source Code

The complete working code for this tutorial is on GitHub:

View source code on GitHub →

What’s Next?

In the next tutorial, we will learn about Custom Layouts and Canvas — drawing your own shapes, progress indicators, and creating layouts that go beyond Column, Row, and Box.

See you there.