Crossfade Switcher
Crossfade between three selectable panels. The animation spec controls how snappy or lazy the swap feels.
Crossfade is the simplest content swap in Compose. You give it a targetState, and whenever that state changes, it fades the previous content out while fading the new content in. There is no slide, no scale, no shared element work to configure. It is a pure opacity transition between two snapshots of your composable. Compared to AnimatedContent, which gives you full control over enter and exit transforms, Crossfade is opinionated: opacity is the only channel it touches.
In this article, you'll explore how AnimationExample6 wires three colored panels behind a tab bar, how Crossfade keeps both old and new content alive during the swap, why the animationSpec argument controls the entire perceived feel of the transition, and how to tune that spec for content sized regions versus small UI affordances.
How the example is structured
The example is a compact two part layout. A Row of buttons acts as the selector, and a Crossfade underneath it renders whichever panel matches the currently selected index. The panel itself is a rounded Surface with an emoji and a label, and each tab carries its own background color so the swap is visible at a glance.
The three tabs are stored as a list of triples, pairing an emoji, a label, and a background color:
val color1 = Color(0xFFFFB74D)
val color2 = Color(0xFF4FC3F7)
val color3 = Color(0xFF5C6BC0)
val tabs = listOf(
Triple("🌅", "Morning", color1),
Triple("🌞", "Noon", color2),
Triple("🌙", "Night", color3),
)
var selected by remember { mutableIntStateOf(0) }
The selected index is the only piece of state that drives the animation. When a button is tapped, selected flips to a new value, and that single change is what Crossfade reacts to. Notice that nothing else in the tree is animated explicitly. The tab buttons themselves switch between Button and OutlinedButton based on isSelected, but that is a normal recomposition, not an animated transition.
The selector row reads directly from tabs and emits a filled or outlined button per index:
tabs.forEachIndexed { index, (emoji, name, _) ->
val isSelected = index == selected
if (isSelected) {
Button(onClick = { selected = index }, ...) {
Text(text = "$emoji $name", fontSize = 14.sp)
}
} else {
OutlinedButton(onClick = { selected = index }, ...) {
Text(text = "$emoji $name", fontSize = 14.sp)
}
}
}
This split between an unanimated selector and an animated content area is the typical shape for Crossfade usage. The control surface stays instant so taps feel responsive, and only the larger payload animates.
Crossfade under the hood
Here is the actual Crossfade call from the example:
Crossfade(
targetState = selected,
animationSpec = tween(crossfadeDurationMs),
label = "tab-crossfade",
) { current ->
val (emoji, name, color) = tabs[current]
Surface(
modifier = Modifier.fillMaxWidth().height(260.dp),
shape = RoundedCornerShape(20.dp),
color = color,
) { ... }
}
The content lambda receives current, which is the snapshotted state value for whichever panel is being rendered. This is important. While the animation is running, the lambda is invoked with both the previous value and the new value. Crossfade keeps both compositions alive at the same time, stacks them, and animates an alpha on each so that one fades down to zero while the other ramps from zero up to one.
Internally, Crossfade is built on top of an updateTransition. When targetState changes, the transition steps from the old key to the new one, and Crossfade keeps a list of currently visible items keyed by their state value. Each entry has its own animateFloat driven by the supplied animationSpec. When an item's alpha reaches zero, it is removed from the list and its composition is disposed. So at any moment during the swap, you have two subtrees in the layout: the outgoing one with decreasing alpha and the incoming one with increasing alpha.
This has two practical consequences. First, the lambda runs more than once per frame during a transition, so the body should be cheap to recompose and free of heavy side effects on entry. Second, both panels occupy the same slot in the layout, which is why Crossfade works best when the swapped content has consistent bounds. The example pins the surface to fillMaxWidth().height(260.dp) precisely so that the outgoing and incoming panels overlap perfectly.
Why the spec defines the feel
The example exposes a single tuning constant:
val crossfadeDurationMs = 6600 // Try snap (1) or slow (2000) for very different feels.
Crossfade(
targetState = selected,
animationSpec = tween(crossfadeDurationMs),
label = "tab-crossfade",
) { ... }
tween(6600) is a linear interpolation across 6.6 seconds. That is intentionally exaggerated for the demo. It lets you actually see both panels coexisting on screen, with one orange surface ghosting through a blue one as the swap progresses. In a real app you would never pick a duration this long for a tab swap, but it makes the underlying mechanic visible.
The animationSpec parameter is what defines the perceived weight of the transition. A tween(150) is fast enough that the eye barely registers an overlap, so the swap reads as a clean replace with a soft edge. A tween(2000) lingers in the middle, where both panels are roughly half opacity and the screen looks washed out for a beat. A tween(durationMillis = 400, easing = EaseOut) decelerates the alpha curve so the new panel arrives slowly at the end and the outgoing one drops away early. That asymmetry shifts attention toward the new content rather than the departing one.
The choice matters more for content swaps than for UI swaps. When you are swapping a card body, a chart panel, or a media surface, a slightly longer spec around 200 to 300 ms gives the eye time to register that the content has changed without making the UI feel sluggish. When you are swapping a small icon or a button label, anything over 150 ms starts to feel laggy because the surrounding UI is implicitly promising instant feedback. The spec is the only knob that distinguishes a tasteful, intentional transition from one that feels broken.
Tweaking the spec and content
Try replacing the spec with tween(150) first. The panels will snap with just a hint of fade, which is the default sweet spot for tab style swaps. Then try snap(). The transition disappears entirely and Crossfade degenerates into a plain if swap. This is useful when a user preference disables motion or when you want to confirm that no other animations leak into the swap.
For a softer landing, try tween(durationMillis = 350, easing = FastOutSlowInEasing). The outgoing panel drops away quickly and the incoming one settles in gently, which suits content that you want the user to dwell on. For a heavier, more cinematic feel, try spring(stiffness = Spring.StiffnessLow). Spring specs work fine with Crossfade because the underlying transition only animates a single float per item.
You can also vary the content while keeping the spec fixed. Replace the colored Surface with an Image and the swap becomes a simple gallery transition. Replace it with a chart composable that recomposes on a different data slice and you get a smooth visual rebuild without writing any per element animation. The same Crossfade adapts because it only cares about the state key, not what the content actually renders.
Conclusion
In this article, you've explored the smallest useful animation primitive in Compose. AnimationExample6 reduces a content swap to one piece of state, one Crossfade call, and one animationSpec. The selector is a normal Row of buttons, and the animated region is a fixed size Surface that simply rebinds to whichever tab is currently selected. The exaggerated 6600 ms duration in the source exists so the overlap is visible, but the same call structure works for any duration you choose.
Understanding what Crossfade does mechanically helps you decide when to reach for it versus AnimatedContent. Crossfade keeps both the outgoing and incoming subtrees composed at the same time and animates their alpha through the supplied spec. That is its only job. If you need to slide, scale, or coordinate enter and exit independently, you want AnimatedContent. If you only want one thing to dissolve into another, Crossfade is the lightest tool that does it well.
Whether you are building a tabbed dashboard, swapping the body of a settings panel, or transitioning between loading and loaded states for a content surface, the lesson is the same. Pick a spec that matches the weight of what you are swapping. Short tweens for UI controls, slightly longer eased tweens for content regions, and snap or near snap when motion should be suppressed. The animationSpec argument carries the entire feel of the interaction, so spend the time to tune it intentionally.
As always, happy coding!
— Jaewoong (skydoves)