Compose Animation Explained
UI › Compose UI
Compose animation interview questions are really API-selection questions. There are two families of tools, and picking the right one for the job is the skill being tested.
The **declarative** family reacts to state on its own: animate*AsState, AnimatedVisibility, Crossfade, AnimatedContent, and updateTransition all take a target value or target state, and animate toward it whenever that target changes. You never call start(), and you never touch a coroutine directly.
Animatable is the odd one out. It's the low-level, imperative primitive underneath those declarative APIs: you hold a single value and move it yourself, from a coroutine, by calling animateTo or snapTo.
The default should be declarative: reach for it whenever something is animating because state changed. Drop to Animatable when you need control the declarative APIs can't give you: following a gesture in real time, sequencing several animations one after another, or interrupting an animation mid-flight with a different target.
animateFloatAsState, and its siblings animateDpAsState, animateColorAsState, animateIntAsState and others, all return a State<T>. Reading that State in composition, usually with by, is what makes the composable recompose as the value ticks toward its target:
var expanded by remember { mutableStateOf(false) }
val size by animateDpAsState(
targetValue = if (expanded) 200.dp else 100.dp,
label = "size"
)
Box(Modifier.size(size).clickable { expanded = !expanded })
There's no start() to call. Flip expanded, the next composition passes a new targetValue, and the returned State animates toward it on its own across frames. This is the idiomatic choice whenever a single value needs to react to a single piece of state, like a size or an offset flipping with a boolean.
Color needs its own variant because you can't interpolate red, green and blue as three independent floats and get a correct result, color spaces don't work that way. animateColorAsState carries the right converter for Color internally, so it interpolates correctly:
var selected by remember { mutableStateOf(false) }
val bgColor by animateColorAsState(
targetValue = if (selected) Color.Blue else Color.LightGray,
label = "bgColor"
)
Box(Modifier.background(bgColor).clickable { selected = !selected })
animateValueAsState, the generic version, would need you to supply that converter yourself.
AnimatedVisibility shows or hides content, and gives you a transition for free even if you specify nothing:
AnimatedVisibility(visible = isVisible) {
Text("Hello")
}
With no enter or exit supplied, the default **enter is fadeIn() + expandIn()**, and the default **exit is fadeOut() + shrinkOut()**. Content fades in while growing into place, and fades out while shrinking away. EnterTransition and ExitTransition values combine with +, so you override either side by supplying your own combination:
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut()
) { Text("Hello") }
The two parameters are independent, you can customize one and leave the other on its default.
Crossfade and AnimatedContent both swap one composable for another based on a target state, but they solve different problems.
Crossfade only fades: the old content fades out while the new content fades in, at the same position. It's the right tool when a plain fade is genuinely enough.
AnimatedContent is the general version. It takes a transitionSpec that can slide, scale or fade the incoming and outgoing content independently, plus an optional SizeTransform that animates the container's size between the two states:
AnimatedContent(
targetState = currentPage,
transitionSpec = {
(slideInHorizontally { w -> w } + fadeIn()) togetherWith
(slideOutHorizontally { w -> -w } + fadeOut()) using
SizeTransform(clip = false)
},
label = "page"
) { page -> PageContent(page) }
Reach for AnimatedContent the moment the incoming and outgoing content differ in size, or need directional motion, that's exactly what Crossfade can't express.
When several values must all animate together off the **same** target state, staying in lockstep, reach for updateTransition (also exposed as rememberTransition) instead of several independent animate*AsState calls:
enum class CardState { Collapsed, Expanded }
var state by remember { mutableStateOf(CardState.Collapsed) }
val transition = updateTransition(targetState = state, label = "card")
val width by transition.animateDp(label = "width") {
if (it == CardState.Expanded) 300.dp else 150.dp
}
val alpha by transition.animateFloat(label = "alpha") {
if (it == CardState.Expanded) 1f else 0.4f
}
width and alpha are both children of the same Transition, driven by the same state flip, so they start and finish together and show up as one coordinated unit in animation tooling. Three separate animate*AsState calls would each react to state independently, with no guarantee they stay aligned.
Animatable holds one value and you move it yourself, with two suspend functions. snapTo jumps to a value instantly, cancelling any animation in flight. animateTo interpolates to a value over time using an animation spec.
That pairing is exactly what a drag gesture needs: snap to follow the finger with zero lag, then animate to settle once the finger lifts.
val offsetX = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
Box(
Modifier.pointerInput(Unit) {
detectDragGestures(
onDrag = { _, drag ->
scope.launch { offsetX.snapTo(offsetX.value + drag.x) }
},
onDragEnd = {
scope.launch { offsetX.animateTo(0f) }
}
)
}.offset { IntOffset(offsetX.value.roundToInt(), 0) }
)
Both snapTo and animateTo are suspend, so calling either from a gesture or click callback means launching it on a CoroutineScope, here rememberCoroutineScope(). This is what makes Animatable suit gestures where the declarative APIs can't: you decide exactly when the value snaps and when it eases.
The same suspend constraint applies outside gestures. Say a Button click should trigger animateTo. You can't call a suspend function directly in a composable's body, composition isn't a coroutine, so you need somewhere to launch it from.
val anim = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
Button(onClick = {
scope.launch {
anim.animateTo(1f, animationSpec = tween(400))
}
}) {
Text("Animate")
}
rememberCoroutineScope() gives you a scope tied to the composition's lifetime, and launching on it from inside onClick is the correct pattern. LaunchedEffect(Unit) would be the wrong tool here: it runs once when the composable enters composition, not once per click, so it wouldn't fire again on a second tap. And calling animateTo straight in the composable body doesn't work either, Button's onClick is a plain lambda, and the composable body itself isn't a suspend context.
Every animate* call and Animatable.animateTo takes an animationSpec, which controls the *how* of the interpolation, independent of which API is driving it.
tween(durationMillis, easing) is duration-based and predictable, defaulting to 300ms with an easing curve.
spring(dampingRatio, stiffness) is physics-based, with no fixed duration. It's tuned by damping and stiffness rather than time, and it can pick up the current velocity of an in-flight animation, which is what makes it feel natural when a target keeps changing mid-motion, a common case in UI. spring is the default spec for most animate*AsState calls for exactly that reason.
keyframes { ... } lets you declare explicit values at specific millisecond timestamps within one total duration, useful when a value needs to pass through an intermediate point rather than moving straight from start to end:
val scale by animateFloatAsState(
targetValue = 1f,
animationSpec = keyframes {
durationMillis = 300
0f at 0
1.2f at 150
1f at 300
},
label = "scale"
)
snap() is the simplest spec of all: no interpolation, the value jumps straight to its target.
For an animation that never stops, a loading spinner or a pulsing badge, none of the finite APIs fit: they all animate to a target and then sit still. rememberInfiniteTransition paired with an infiniteRepeatable spec is built for exactly this, and it stops automatically when it leaves composition, so you don't manage a coroutine loop yourself:
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
val scale by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 1.2f,
animationSpec = infiniteRepeatable(
animation = tween(600),
repeatMode = RepeatMode.Reverse
),
label = "scale"
)
Box(Modifier.scale(scale))
High-frequency animations like this deserve one more detail: where you read the value matters. Reading scale directly in a modifier like Modifier.scale(scale) or Modifier.rotate(value) means every frame re-triggers composition. Reading it inside Modifier.graphicsLayer { } instead defers the read to the draw phase:
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = null,
modifier = Modifier.graphicsLayer { rotationZ = rotation }
)
Because the lambda passed to graphicsLayer only runs at draw time, each frame updates the drawn rotation without re-running composition or layout, which is the performant way to apply a continuously changing value.
animateContentSize() is a modifier, not a state API. Drop it on any composable and it animates that composable smoothly between its old and new **measured size** whenever its content changes:
var expanded by remember { mutableStateOf(false) }
Card(
Modifier
.animateContentSize()
.clickable { expanded = !expanded }
) {
Text(if (expanded) longDescription else "Tap to expand")
}
There's no target value to compute and no State to read, it watches whatever size the layout naturally resolves to and eases between values. Chain order matters here: place animateContentSize() before other size-affecting modifiers, like padding, so their contribution to the final size is included in what gets animated. Put it after padding instead, and the padding is applied to an already-animated size, which isn't what you want.
Lists need their own answer, because items in a LazyColumn are constantly inserted, removed and reordered, and none of the APIs so far animate *where* an item sits in a layout. Modifier.animateItem(), applied inside the item content, animates a lazy list item's appearance, disappearance and position change automatically:
LazyColumn {
items(
items = myList,
key = { it.id }
) { item ->
Text(
text = item.name,
modifier = Modifier.animateItem()
)
}
}
It needs a stable key on the items call to track which item is which across list changes, without one it can't tell an insertion from a reorder. animateContentSize() is a different job: it animates a single composable's own size, not its position within a list. animateItem() is the one built for placement.
Every choice in this topic comes down to the same question: what's driving the motion, and how many things need to move together?
A single value reacting to a state flip is animate*AsState. Several values that must move in lockstep off one target state is updateTransition. Swapping content is Crossfade for a plain fade, AnimatedContent when size or direction matters, and AnimatedVisibility for show or hide with a sensible default. A layout's own size responding to its content is animateContentSize(). Motion that never ends is rememberInfiniteTransition. Items moving inside a list is animateItem().
Animatable is what you reach for once none of those fit: a gesture you're tracking frame by frame, a sequence of animations chained together, or an animation you need to interrupt mid-flight. Everything else in Compose animation is built on top of it.
In an interview, say the decision out loud in that order: what's the target, is it one value or several, is it finite or continuous, and only fall back to Animatable once you've ruled out the declarative side. That's the answer that shows you're choosing the right tool, not just the one you remember.