AnimatedContent vs Crossfade in Compose

Quick answer: use
Crossfadewhen one layout should simply fade into another. UseAnimatedContentwhen content states need a deliberate transition—such as a slide direction, a size transform, or different behavior for forward and backward changes. Both keep outgoing and incoming content around during the transition, so render from the target-state lambda rather than an outer mutable variable.
The Compose animation quick guide makes the intended split explicit: AnimatedContent animates between composables, while Crossfade is the choice for a standard fade. Start with the smallest API that communicates the state change, then move to AnimatedContent only when the relationship between states needs more than opacity.
The decision in one table
| Requirement | Use | Reason |
|---|---|---|
| Replace one content layout with another through opacity only | Crossfade | A focused API with a FiniteAnimationSpec<Float> |
| Add slide, scale, or a different transform for each state change | AnimatedContent | Its transitionSpec defines a ContentTransform |
| Animate the container as content sizes differ | AnimatedContent | It supports a SizeTransform and animates size by default |
| Animate whether a content tree exists | AnimatedVisibility | This is appearance/disappearance, not replacement |
| Coordinate values such as color and elevation with a state machine | updateTransition | The state transition owns multiple value animations |
Crossfade is not an inferior AnimatedContent; it is the honest choice when fading is the entire visual language of the state change. A settings pane that swaps between two equivalent views often benefits from that restraint. A paged summary where “next” and “previous” need spatial meaning does not.
Crossfade: a simple state-to-content mapping
Pass a target state and render the content from the lambda parameter. When the target key changes, the old content fades out while the new content fades in.
import androidx.compose.animation.Crossfade
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
enum class DashboardPanel {
Overview,
Activity,
}
@Composable
fun DashboardBody(panel: DashboardPanel) {
Crossfade(
targetState = panel,
label = "dashboard panel",
) { targetPanel ->
when (targetPanel) {
DashboardPanel.Overview -> Text("Overview")
DashboardPanel.Activity -> Text("Activity")
}
}
}The Crossfade API reference calls targetState a key: each changed key starts a transition. The content lambda can be invoked for both the old and new states while they overlap, which is why targetPanel must drive the when expression.
The default animation spec is a tween. You can provide another finite spec, but if the desired answer becomes “slide when moving forward, slide the opposite way when moving backward, and resize differently,” that is a sign to use AnimatedContent.
AnimatedContent: model the relationship between states
AnimatedContent takes the same state-to-content idea further. Its transitionSpec lets you define the enter and exit transform, including a different transform for each initial/target state pair.
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.runtime.Composable
import androidx.compose.material3.Text
@Composable
fun StepBody(step: Int) {
AnimatedContent(
targetState = step,
transitionSpec = {
if (targetState > initialState) {
slideIntoContainer(
towards = SlideDirection.Left,
) + fadeIn() togetherWith slideOutOfContainer(
towards = SlideDirection.Left,
) + fadeOut()
} else {
slideIntoContainer(
towards = SlideDirection.Right,
) + fadeIn() togetherWith slideOutOfContainer(
towards = SlideDirection.Right,
) + fadeOut()
}
},
label = "step body",
) { targetStep ->
Text("Step ${targetStep + 1}")
}
}The incoming and outgoing elements are both present during the transform. The directional helpers calculate distance from the content container, which is generally safer than hard-coding an offset that happens to fit one device. The official animation documentation describes these helpers as alternatives that base the slide distance on initial and target content sizes.
The example uses the content lambda’s targetStep, not the outer step, for the same reason as Crossfade: Compose asks for both versions of the UI while animating. Android’s AnimatedContent reference labels this requirement as critical for correctly looking up incoming and outgoing content.
Size is part of the difference
When the replacement content has different dimensions, AnimatedContent animates its container size by default. That can be a polished outcome for a compact loading card that becomes a detailed result, but it can also be unwanted movement in a stable screen layout.
Use SizeTransform when the size change needs its own timing or clipping policy:
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.animation.using
import androidx.compose.animation.core.tween
AnimatedContent(
targetState = expanded,
transitionSpec = {
fadeIn(animationSpec = tween(180))
.togetherWith(fadeOut(animationSpec = tween(90)))
.using(SizeTransform(clip = false))
},
label = "details body",
) { targetExpanded ->
if (targetExpanded) {
ExpandedDetails()
} else {
CompactDetails()
}
}clip = false is useful only if content is intentionally allowed to draw outside the animated size. Keep clipping enabled by default when overflow would expose unrelated content. The SizeTransform reference documents both the size-animation role and the clipping behavior.
Avoid unnecessary transitions with contentKey
targetState does not need to be a tiny enum. It can be a richer UI model, but a newly created object or a frequently changing field can accidentally produce a new animation for content that is effectively the same.
data class ResultUiState(
val mode: ResultMode,
val refreshedAtMillis: Long,
)
AnimatedContent(
targetState = uiState,
contentKey = { state -> state.mode },
label = "result mode",
) { state ->
when (state.mode) {
ResultMode.Loading -> LoadingPanel()
ResultMode.Content -> ContentPanel()
ResultMode.Error -> ErrorPanel()
}
}Here, a refresh timestamp can change without forcing a content-replacement animation. By default, the content key is the target-state object itself. Use contentKey only when multiple state values truly map to the same visual content identity; otherwise it can hide a transition the user should see.
Do not confuse replacement, visibility, and navigation
These APIs look similar because all of them animate composables, but their responsibilities are different.
AnimatedVisibilitycontrols whether one content tree exists. Use it for a dismissible tip or inline error; see AnimatedVisibility in Compose.CrossfadeandAnimatedContentreplace old content with new content in a single container.- Navigation transitions animate route changes and must respect the back stack. Use the transition APIs in your navigation setup rather than embedding a whole destination switch in
AnimatedContent. animate*AsStatechanges a value on content that remains present, such as color or scale. Seeanimate*AsStatefor that target-based pattern.
Keeping the UI state explicit makes this choice easier. A Loading, Content, or Error state can be rendered through AnimatedContent; a transient navigation command should not. UI state, events, and one-time effects explains why those categories have different lifecycles.
Common mistakes
Ignoring the lambda state parameter
This is the most important mistake. Do not write when (currentState) inside the content lambda. While outgoing and incoming content overlap, currentState only describes the latest state, so both slots can render the same UI. Render from the parameter supplied to the lambda.
Adding a transition for every small value change
Both APIs compose outgoing and incoming content simultaneously during an animation. Do not crossfade a search result for every keystroke or animate a clock tick. Derive a stable visual category first, or update the existing content directly.
Relying on a fade when direction is meaningful
A fade does not explain whether the user advanced or went back. For pages, ordered steps, or a hierarchy drill-down, encode direction with AnimatedContent rather than making two unrelated screens dissolve into one another.
Using AnimatedContent when the screen must not resize
If content size changes create unwanted layout movement, define an explicit SizeTransform, constrain the container, or choose a simple Crossfade where it better matches the design. Test narrow and large screens rather than judging only one preview.
Test both identities during the handoff
Test the target state change and the visual contract separately. Verify that the semantic content for the new state appears, then use a controlled Compose test clock or screenshot test when overlap, direction, or size timing matters. For contentKey, test two states that share a key and two that do not.
Give each animation a clear label. Android Studio’s Animation Preview supports both AnimatedContent and Crossfade, which is useful for confirming that a “simple” transition has not become a confusing one.
FAQ
Is Crossfade implemented with AnimatedContent?
You do not need to depend on its implementation. Treat Crossfade as the public API for a standard fade and use AnimatedContent when you need its additional transition controls.
Can AnimatedContent animate more than two states?
Yes. Its target state can be an enum, sealed UI state, number, or another type. The content lambda maps every supported target state to its UI.
When should I skip animation entirely?
Skip it when it delays critical feedback, repeats frequently, or does not clarify the relationship between states. A direct state update is often the clearest interaction.