You don't animate from A to B, you declare B and Compose handles the rest
animate*AsState animates toward a target that can change at any moment. Understanding it as declarative rather than imperative explains interruption, redirection, and why spring is the right default.

Day 62 of 100. The most-used animation API, and one property of it that explains why Compose animations feel different from View animations.
The symptom
A View-system reflex, written in Compose:
var alpha by remember { mutableStateOf(0f) }
LaunchedEffect(visible) {
val start = alpha
val end = if (visible) 1f else 0f
animate(start, end) { value, _ -> alpha = value } // from-to
}
Toggle visible rapidly and it stutters. Each toggle cancels the effect and starts a new
animation from wherever the last one stopped, with a fresh duration — so a fast toggle
produces a sequence of partial animations rather than one that changes direction.
Why the obvious fix fails
The obvious fix is to track velocity manually, or to debounce the toggle so animations can't overlap. Debouncing makes the UI feel unresponsive; tracking velocity is re-implementing a spring.
The framing is the problem. animate(start, end) describes a journey, and journeys
don't compose — a second journey starting mid-first has to decide what to do about the
first.

The actual mechanism
animate*AsState takes a target, not a range:
val alpha by animateFloatAsState(
targetValue = if (visible) 1f else 0f,
label = "alpha",
)
There is no start value in the API, because the start is wherever the value currently is. The animation is a continuous process converging on a target; changing the target redirects it in flight, carrying the current value and velocity forward.
That's why a rapid toggle looks smooth here and stuttered above: with a spring() spec,
the reversal preserves momentum, so the value decelerates and comes back the way a
physical object would.
The declarative framing is the same one from Day 39 about dialogs and Day 5 about the UI as a whole: a fact that changes, not a command with a moment attached.
Which spec, and why spring is the default
animateFloatAsState(target, animationSpec = spring()) // physics
animateFloatAsState(target, animationSpec = tween(300)) // duration + curve
A tween has a fixed duration. Interrupt it and the new tween starts at the current value
with a fresh duration and zero velocity — a visible hitch at the reversal point.
A spring has stiffness and damping instead of a duration. It has velocity as part of its
state, so redirection is continuous. That is the whole argument, and it's why Compose
defaults to it.
The two parameters worth knowing:
spring(
dampingRatio = Spring.DampingRatioMediumBouncy, // overshoot
stiffness = Spring.StiffnessLow, // speed
)
DampingRatioNoBouncy settles without overshoot — right for most UI. The bouncy ratios
suit playful motion and read as sloppy on a form. StiffnessMedium to StiffnessLow is
the useful range; the default (StiffnessMedium) is fine for nearly everything.
Use tween when timing is specified — a designer's 300ms spec, a motion that must
coordinate with a fixed-duration video or a system transition.
The typed variants
animateFloatAsState(…) animateDpAsState(…)
animateColorAsState(…) animateIntAsState(…)
animateOffsetAsState(…) animateSizeAsState(…)
animateRectAsState(…) animateIntOffsetAsState(…)
animateColorAsState is worth calling out: it interpolates in a perceptually reasonable
space rather than naively per-channel, so a blue-to-yellow transition doesn't pass through
grey. Animating the channels yourself with three animateFloatAsState calls does pass
through grey, which is a real and avoidable ugliness.
For a type without a built-in, animateValueAsState plus a TwoWayConverter handles it:
val converter = TwoWayConverter<MyType, AnimationVector2D>(
convertToVector = { AnimationVector2D(it.x, it.y) },
convertFromVector = { MyType(it.v1, it.v2) },
)
val value by animateValueAsState(target, converter, label = "custom")
Everything animatable reduces to a vector of one to four floats — which is why the built-in set is what it is, and why adding your own is a small function rather than a subclass.
The initial-value question
One behaviour that surprises people: animate*AsState does not animate on first
composition. It starts at the target and animates only on subsequent changes.
That's the right default — a screen whose elements all animate in from zero on every
appearance is noisy, and it would fight LazyColumn's item reuse badly.
When you do want an entrance animation, the target has to change after the first frame:
var appeared by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { appeared = true }
val alpha by animateFloatAsState(if (appeared) 1f else 0f, label = "enter")
That works and is usually the wrong tool — an element appearing is Day 64's
AnimatedVisibility, which handles the composition lifecycle as well as the value. The
LaunchedEffect trick is worth knowing mainly so you recognise it as a workaround when
you meet it in existing code.
finishedListener, and when not to use it
val alpha by animateFloatAsState(
targetValue = target,
label = "alpha",
finishedListener = { finalValue -> onAnimationDone() },
)
Useful for chaining, and a trap for logic. The listener doesn't fire if the animation is
interrupted and redirected, so anything essential placed there can be skipped. If the
follow-up work must happen, drive it from the state change rather than from the animation
completing — or use Animatable, where animateTo is a suspend function that either
returns or is cancelled, which is far easier to reason about.
The performance note
This is Day 10 again and worth repeating in context: animate*AsState returns a State,
and where you read it decides what invalidates.
val offset by animateDpAsState(target, label = "offset")
Box(Modifier.offset(y = offset)) // read in COMPOSITION — recomposes per frame
Box(Modifier.offset { IntOffset(0, offsetPx) }) // read in LAYOUT — doesn't
For a value animating at 60fps, prefer the lambda form of the modifier. graphicsLayer
takes a lambda for the same reason:
Box(Modifier.graphicsLayer { alpha = animatedAlpha }) // draw phase only
That single habit is most of the difference between an animation that costs a recomposition per frame and one that costs nothing above the draw.
How to prove it
The interruption behaviour is the thing to see:
@Preview
@Composable fun InterruptionComparison() {
var target by remember { mutableStateOf(0.dp) }
Column {
Button(onClick = { target = if (target == 0.dp) 200.dp else 0.dp }) { Text("Toggle") }
Box(Modifier.offset(y = animateDpAsState(target, spring(), label = "s").value))
Box(Modifier.offset(y = animateDpAsState(target, tween(600), label = "t").value))
}
}
Toggle twice quickly. The spring reverses smoothly; the tween visibly restarts. Once seen, the default stops looking arbitrary.
For the phase claim, Layout Inspector's recomposition counts on the two Box variants:
value-read climbs at 60/second, lambda-read stays flat.
What this generalizes to
The principle is declare the destination, not the path. A target is a fact that can be revised; a journey is a plan that has to be cancelled and replanned. Revision composes; cancellation doesn't.
It's the same shift as the rest of Compose — dialog is ConfirmDelete over showDialog(),
a state target over an animation command — and the payoff is identical each time. The hard
cases (interruption, reversal, overlap) stop being special cases, because there was never a
plan to invalidate.
Physics engines and layout constraint solvers reached the same conclusion decades ago: describe the equilibrium and let the system converge, because a converging system handles a changed goal for free while a scripted one has to be torn down and rebuilt.
Tomorrow, Day 63: updateTransition — coordinating several values, and the state machine
underneath.
Day 62 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Value-based animations.