Crossfade & AnimatedContent

AnimatedVisibility answers "is this one composable here or not?" A different, equally common problem is swapping between two or more entirely different composables based on some state — a spinner replaced by loaded content, one wizard step replaced by the next. Compose gives you two tools for that: Crossfade, a minimal fade-between-states helper, and AnimatedContent, which gives you full control over exactly how each transition enters and exits, including different motion depending on which direction the state changed.

Crossfade: The Simple Case

When all you want is "fade the old content out while fading the new content in," Crossfade is the least code you can write to get there. It takes a targetState and a content lambda keyed on that state:

Crossfade between a spinner and content

@Composable
fun RecipeDetailContent(isLoading: Boolean, recipe: Recipe?) {
    Crossfade(targetState = isLoading, label = "recipeLoadingCrossfade") { loading ->
        if (loading) {
            Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                CircularProgressIndicator()
            }
        } else {
            recipe?.let { RecipeDetailBody(it) }
        }
    }
}

Crossfade is deliberately limited to a fade — there's no way to make the incoming content slide in from the side, for example. When a fade is all the design calls for, that limitation is exactly what keeps the call site so small.

AnimatedContent: Full Control Per Direction

AnimatedContent covers the same "swap between states" problem but lets you specify a transitionSpec lambda that decides the enter/exit transitions for each specific state change, using targetState and initialState to tell them apart:

Different transitions depending on direction

@Composable
fun AddRecipeSteps(currentStep: Int) {
    AnimatedContent(
        targetState = currentStep,
        transitionSpec = {
            if (targetState > initialState) {
                // moving forward: new step slides in from the right
                slideInHorizontally { width -> width } togetherWith
                    slideOutHorizontally { width -> -width }
            } else {
                // moving back: new step slides in from the left
                slideInHorizontally { width -> -width } togetherWith
                    slideOutHorizontally { width -> width }
            }
        },
        label = "addRecipeStep"
    ) { step ->
        when (step) {
            0 -> RecipeBasicInfoStep()
            1 -> RecipeIngredientsStep()
            else -> RecipeStepsStep()
        }
    }
}

togetherWith pairs one EnterTransition with one ExitTransition into a single ContentTransform that AnimatedContent plays for that particular state change — this is what lets forward navigation slide one way and backward navigation slide the other, something Crossfade simply has no parameters for.

Worked Example: RecipeBox's Loading-to-Content Transition

RecipeBox's recipe detail screen wants a bit more polish than a plain crossfade: the spinner should fade out quickly, but the loaded content should fade in and expand into place, since it's usually taller than the spinner it replaces. That asymmetry is exactly what AnimatedContent is for:

RecipeBox: AnimatedContent keyed on the loading flag

@Composable
fun RecipeDetailScreen(isLoading: Boolean, recipe: Recipe?) {
    AnimatedContent(
        targetState = isLoading,
        transitionSpec = {
            if (targetState) {
                fadeIn(tween(150)) togetherWith fadeOut(tween(150))
            } else {
                (fadeIn(tween(300)) + expandVertically(tween(300))) togetherWith
                    fadeOut(tween(150))
            }
        },
        label = "recipeLoadingContent"
    ) { loading ->
        if (loading) {
            Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                CircularProgressIndicator()
            }
        } else {
            recipe?.let { RecipeDetailBody(it) }
        }
    }
}

Because targetState is a plain Boolean here rather than an ordered step index, the branch checks the boolean directly instead of comparing against initialState — the same pattern from the wizard example above, just simplified for a two-state toggle.

Which One Should You Reach For?

UseWhen
CrossfadeA plain fade between states is all the design needs, and you want the smallest possible call site
AnimatedContentYou need different motion per direction, non-fade transitions (slide, expand/shrink), or want the content to affect surrounding layout size as it transitions
Next up: Both tools here animate one state-driven swap. The Transition API covers coordinating several independently animated properties off of the same state change.