Animate Content Size
Expandable card that morphs height between two Dp values via animateDpAsState with a tween spec.
Most Compose developers reach for animateDpAsState the first time they need a value to glide between two sizes instead of snapping. The API reads as a one liner: pass a target Dp, get back a State<Dp> that interpolates whenever the target changes. The deeper question is what the animation spec actually does between those two endpoints, and how small choices in duration and easing change the way the motion feels in your hand. This article works through a single expandable card that morphs its height between two Dp values, and treats the spec as the real subject.
In this article, you'll explore how a single animateDpAsState call drives an expandable card, how the tween spec wires duration and easing together, what FastOutSlowInEasing does to the unfold compared to LinearEasing and FastOutLinearInEasing, and how each constant at the top of the composable changes the perceived motion.
How the example is structured
The composable lives in AnimationExample1.kt as a single function, AnimationExample1. On screen, you see a title row above a Card whose content is a column of body text and a small caret indicator. Tapping the card flips a single boolean, and that boolean is the only state that drives the animation. Everything else, the typography, the surface color, the padding, is static.
The state declaration sits right at the top of the body:
var isExpanded by remember { mutableStateOf(false) }
That Boolean is the source of truth. The animation does not track scroll position or drag offsets. It reads isExpanded, picks one of two target heights, and lets animateDpAsState interpolate between the previous value and the new one whenever the target changes. The Card itself just applies Modifier.height(animatedHeight), so its measured height is whatever the animation reports on the current frame.
The constants that shape the motion are also declared inside the composable, deliberately above the state. Placing them at the top makes them trivial to tweak with hot reload: edit the value, save, and the running composable picks up the new number on the next recomposition without losing the current isExpanded state.
animateDpAsState with a tween spec
Four constants describe the entire animation:
val EXPAND_DURATION_MS = 400
val EXPAND_EASING = FastOutSlowInEasing
val COLLAPSED_HEIGHT: Dp = 160.dp
val EXPANDED_HEIGHT: Dp = 330.dp
COLLAPSED_HEIGHT and EXPANDED_HEIGHT are the two endpoints the card moves between. EXPAND_DURATION_MS defines how long that motion takes in milliseconds, and EXPAND_EASING defines the shape of the curve that maps elapsed time to interpolation progress. The names are loud on purpose. They read like configuration, not like one off literals buried inside a Modifier chain.
The animation itself is a single call:
val animatedHeight by animateDpAsState(
targetValue = if (isExpanded) EXPANDED_HEIGHT else COLLAPSED_HEIGHT,
animationSpec = tween(
durationMillis = EXPAND_DURATION_MS,
easing = EXPAND_EASING,
),
label = "cardHeight",
)
Three things are worth noticing. First, targetValue is computed on every recomposition from isExpanded. When the boolean flips, the target changes, and animateDpAsState notices the change and starts a new animation toward the new target. Second, the spec is a tween, which is a duration based interpolation rather than a physics simulation. There is no velocity carried over, no bounce, no overshoot. Given a start value, an end value, and a curve, tween produces a deterministic value at every frame. Third, the label is a debugging aid for tooling like the Animation Preview, not part of the animation itself.
Because tween is duration based, the choice of EXPAND_DURATION_MS and EXPAND_EASING together fully describe the timing. The endpoints are independent: changing COLLAPSED_HEIGHT or EXPANDED_HEIGHT only changes how far the card travels, not how long it takes.
What FastOutSlowInEasing does to the unfold
FastOutSlowInEasing is the standard Material easing curve. In plain terms, it accelerates quickly from the starting value, then decelerates as it approaches the end. The curve starts steep, flattens out, and finishes gently. When you watch the card open, the first part of the motion feels brisk, and the last part feels like the card is settling into place rather than slamming to a stop.
Compare that to the two alternatives the source comments call out:
- LinearEasing: progress is a straight line from 0 to 1. Every frame moves the same number of pixels. The card opens at a constant rate, which can feel mechanical because nothing in the physical world starts and stops without acceleration.
- FastOutLinearInEasing: accelerates out of the start the same way as
FastOutSlowInEasing, but then continues at a constant rate into the end instead of decelerating. The motion feels like it gets faster and then cuts off, which is appropriate for elements moving off screen but harsh for something that should rest at its destination.
For a card that opens and stays put, FastOutSlowInEasing is the right shape. The deceleration at the end matches the perception that the surface has come to rest. For a card that exits the screen, you would reach for an easing that accelerates into the end instead. The easing is not decorative. It encodes the physical metaphor of what the element is doing.
Tweaking the constants on a running device
Each constant has a distinct effect on the feel of the animation. Walking through them one by one:
- EXPAND_DURATION_MS: at 400, the unfold reads as deliberate without feeling slow. Drop it to around 150 and the motion becomes snappy, almost instant, which works well for utility surfaces but loses the sense that something is opening. Push it past 700 and the card starts to feel sluggish, especially on repeated taps where the user is waiting for the previous animation to finish.
- EXPAND_EASING: swap to
LinearEasingand the card opens at a constant pace, which makes the motion feel rigid. Swap toFastOutLinearInEasingand the card hits the expanded height at full speed, which reads as abrupt. KeepingFastOutSlowInEasingproduces the soft landing that matches the rest of Material motion. - COLLAPSED_HEIGHT: at
160.dp, the collapsed card shows the title row and a couple of lines of body text. Lower it to around80.dpand only the title row remains visible, turning the collapsed state into a header. Raise it close toEXPANDED_HEIGHTand the animation has almost no distance to cover, so the unfold becomes a subtle nudge rather than a clear gesture. - EXPANDED_HEIGHT: at
330.dp, the card comfortably shows all five paragraphs with padding to spare. Reduce it and the bottom paragraphs clip behind the card edge while the layout still claims they exist. Increase it well past the content size and the bottom of the card becomes empty space, which usually signals that the height should be driven by content, not a fixedDp.
Because the constants live at the top of the composable, you can edit any of them, save, and watch the next tap honor the new value without restarting the app. That tight loop makes the differences between, say, FastOutSlowInEasing and LinearEasing something you feel rather than reason about.
Conclusion
In this article, you've explored a minimal expandable card built from a single animateDpAsState call backed by a tween spec. You walked through the four constants that fully describe the motion, the way targetValue reacts to a boolean flip, and the difference between FastOutSlowInEasing, LinearEasing, and FastOutLinearInEasing when each one drives the same height change.
Understanding these internals helps you treat the animation spec as a deliberate choice rather than a default. animateDpAsState is not magic. It is a deterministic interpolator that takes a target and a curve and produces a value per frame. Once you internalize that, deciding between a tween and a spring, or between two easings, becomes a question about what physical metaphor you want the surface to communicate.
Whether you're building a card that expands inline, a bottom sheet that slides into view, or a thumbnail that grows into a hero image, the same pattern applies: pick endpoints, pick a duration, pick a curve that matches what the element is doing. Hot reload turns those choices into a few seconds of iteration each, which is the fastest way to develop a feel for what each spec actually produces.
As always, happy coding!
— Jaewoong (skydoves)