updateTransition is a state machine that happens to animate
updateTransition coordinates several animations driven by one state. It handles multi-state enums, per-property specs via transitionSpec, and child transitions — all with coherent interruption a group of animate*AsState calls cannot give you.

Day 63 of 100. Day 61 named the multi-value case; this is the API for it, and it does more than group animations together.
The symptom
A component with three states, animated with booleans:
val isLoading = state == State.Loading
val isError = state == State.Error
val bgColor by animateColorAsState(
when { isError -> errorColor; isLoading -> loadingColor; else -> normalColor },
label = "bg",
)
val scale by animateFloatAsState(if (isError) 1.05f else 1f, label = "scale")
Two derived booleans, two independent animations, and a when inside each. Adding a
fourth state means editing every animation and re-deriving every boolean, and the
combinations that shouldn't exist (isLoading && isError) are still representable.
Why the obvious fix fails
The obvious fix is to keep the derived booleans tidier — a sealed class with computed properties, say. That improves the readability of the conditions and leaves the real problem: the animations don't know they belong to the same state change.
Interrupt a Loading → Error transition halfway with a change to Success, and the colour animation and the scale animation each independently decide what to do. They can disagree about where "halfway" was.

The actual mechanism
updateTransition takes the state itself, not a boolean:
val transition = updateTransition(targetState = state, label = "card")
val bgColor by transition.animateColor(label = "bg") { s ->
when (s) {
State.Normal -> normalColor
State.Loading -> loadingColor
State.Error -> errorColor
}
}
val scale by transition.animateFloat(label = "scale") { s ->
if (s == State.Error) 1.05f else 1f
}
Each child animation is a function from state to value. The when is exhaustive over
the enum, so adding a fourth state is a compile error in every place that needs updating —
which is exactly the property the boolean version threw away.
And because one Transition object owns all the children, they share a clock. Interrupt
mid-flight and every child redirects from its own current value at the same moment.
transitionSpec: per-edge timing
The parameter that makes this API worth learning. A child can use a different spec depending on which transition is happening:
val elevation by transition.animateDp(
label = "elevation",
transitionSpec = {
when {
State.Normal isTransitioningTo State.Error -> spring(dampingRatio = 0.4f)
State.Error isTransitioningTo State.Normal -> tween(400)
else -> spring()
}
},
) { s -> if (s == State.Error) 12.dp else 2.dp }
Entering an error state can snap sharply while leaving it settles slowly. That asymmetry
is what makes motion feel designed rather than uniform, and it's not expressible with
animate*AsState at all — a single spec has to serve both directions.
Inside transitionSpec, initialState and targetState are in scope, and
isTransitioningTo is the readable infix form.
Reading where the transition is
The Transition object exposes its own progress:
transition.currentState // where it started
transition.targetState // where it's going
transition.isRunning // still animating?
Useful for driving things that aren't animated values — disabling a button until a transition settles, or triggering a haptic on arrival:
LaunchedEffect(transition.currentState) {
if (transition.currentState == State.Error) haptics.performHapticFeedback(…)
}
currentState updates when the animation completes, so it's the settled value —
analogous to Day 25's settledPage, and the right thing to key side effects on.
Seeded transitions and the segment
Two smaller pieces that come up once a transition gets real use.
transition.segment inside transitionSpec is the initialState → targetState pair, and
it's what isTransitioningTo reads. Matching on it directly is occasionally clearer when
several edges share a spec:
transitionSpec = {
when (segment) {
Segment(State.Loading, State.Error) -> tween(150)
else -> spring()
}
}
And transition.totalDurationNanos reports how long the whole group will take — useful
for coordinating something outside the transition, such as delaying a navigation until the
exit motion has finished rather than guessing a duration that then drifts when the spec
changes.
Child transitions
A Transition can create a nested one via createChildTransition, which is how a
sub-component animates in step with a parent without being passed each value:
val contentTransition = transition.createChildTransition(label = "content") { s ->
s != State.Loading
}
contentTransition.AnimatedVisibility(visible = { it }) { Content() }
The child derives its own state from the parent's, and stays synchronised with it. This is how a screen-level transition can drive several components that each care about a different projection of the same state. It also means the child only re-animates when its projection changes, so a component that looks the same in two of the parent's states stays still while the others move.
Where it fits with the other APIs
Two integrations worth knowing:
Transition.AnimatedVisibility and Transition.AnimatedContent are extension
functions that participate in the parent transition rather than running independently. If
an element's appearance is part of a larger state change, use these rather than the
standalone versions — otherwise its timing is unrelated to everything else.
rememberTransition takes a MutableTransitionState, which lets a transition start
animating on first composition:
val visibleState = remember { MutableTransitionState(false).apply { targetState = true } }
val transition = rememberTransition(visibleState, label = "enter")
That's the clean version of the LaunchedEffect entrance trick from Day 62 — the initial
state and the target differ, so the transition runs immediately.
How to prove it
The coordination claim needs an interrupted transition, which is hard to see at full speed:
@Preview
@Composable fun CardStates() {
var state by remember { mutableStateOf(State.Normal) }
Column {
Row { State.entries.forEach { s -> Button(onClick = { state = s }) { Text("$s") } } }
AnimatedCard(state)
}
}
Android Studio's Animation Preview shows every labelled child on one timeline, with a scrubber and a speed control. Drop to 10% and switch states mid-animation: the transition version keeps its children aligned, and the independent version visibly doesn't.
That inspector only groups the children because they belong to one labelled Transition —
which is the practical argument for the label parameters beyond tidiness. Unlabelled animations
appear as anonymous rows, which is workable with two children and useless with eight.
What this generalizes to
The idea is making the state explicit rather than deriving booleans from it. A transition over an enum can be exhaustive, can vary per edge, and can coordinate everything that depends on it. A set of booleans extracted from that enum can do none of those, and permits combinations the enum ruled out.
It's Day 12's sealed-hierarchy argument arriving in the animation layer: model the states, then define what each one looks like — rather than modelling the differences and hoping they stay consistent.
Design tools have converged on the same representation — Figma's variants, the state-machine editors in motion tools — because a designer specifying "how does it look in each state, and how does each edge behave" maps directly onto this API and not at all onto a pile of booleans.
Tomorrow, Day 64: AnimatedVisibility and AnimatedContent — animating things that
aren't there yet.
Day 63 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Transitions.