FAB Spring Morph

A floating action button that grows, rotates, and shifts color through animateAsState with spring physics.

Compose APIs
animateFloatAsStateanimateDpAsStateanimateColorAsStatespring
Try tweaking
springStiffnessspringDampingRatiotarget sizes and corners

Compose offers two broad ways to animate a value over time. Tween based specs run for a fixed duration along a curve like easeInOut, and physics based specs let a spring settle on the target according to stiffness and damping. The difference shows up the moment you interrupt one. A tween restarts a new curve from the current value to the new target, while a spring keeps its current velocity and bends toward the new target without a visible reset. That continuity is why physics specs feel natural under fast taps and gestures, even though both APIs read the same in your composable.

In this article, you'll explore a single example that morphs a floating action button by toggling one boolean, driving four parallel animateAsState calls, and shaping every channel with spring(...). You'll see how Spring.StiffnessMediumLow and Spring.DampingRatioMediumBouncy translate to a real motion feel, why the same spec can govern size, rotation, corner radius, and color at once, and which constants to tweak first when the motion looks too snappy or too floaty.

How the example is structured

The composable holds a single piece of state, var morphed by remember { mutableStateOf(false) }, that flips on every tap of the FAB. Around that flag, four animateAsState calls observe the boolean and interpolate their own typed value toward a new target whenever morphed changes. Each call returns a State<T> that Compose reads during composition, so when the spring produces a new frame, only the Box that depends on those values invalidates and redraws.

The four animated channels are size in Dp, rotation in degrees, corner radius in Dp, and background color. They all read the same morphed flag but choose their own target pair, which is what gives the morph its distinct character. Size jumps between 74.dp and 196.dp, rotation between 0f and 135f, corner radius between collapsedSize / 2 (a perfect circle) and 24.dp, and color between MaterialTheme.colorScheme.primary and MaterialTheme.colorScheme.secondary.

The visual tree itself is intentionally small. A Column holds a centered Box of fixed 240.dp height, and inside that is the morphing Box that consumes the four animated values through Modifier.size(size).rotate(rotation).background(color = color, shape = RoundedCornerShape(cornerDp)). A clickable { morphed = !morphed } flips the state, and a single + glyph stays centered while the surrounding shape stretches and turns around it.

Spring physics in animateAsState

Each animated value uses the same physics spec, built from two named constants in the Spring object. Looking at the declarations near the top of the composable:

val springStiffness = Spring.StiffnessMediumLow
val springDamping = Spring.DampingRatioMediumBouncy

Stiffness controls how strongly the spring pulls the current value toward the target. You can think of it as the natural frequency of the spring in Hz: higher stiffness means the value reaches the target faster but with sharper acceleration at the start. Compose ships named tiers like Spring.StiffnessLow, Spring.StiffnessMediumLow, Spring.StiffnessMedium, and Spring.StiffnessHigh so you do not have to memorize raw numbers. StiffnessMediumLow is a relaxed setting that gives the morph time to breathe without feeling sluggish.

Damping ratio controls how the spring loses energy as it approaches the target. A value of 1f is critically damped, which means the spring reaches the target in the shortest time without overshooting. Values below 1f underdamp the motion and let it wobble past the target before settling, and values above 1f overdamp it and make the motion feel cushioned. Spring.DampingRatioMediumBouncy sits below 1f, so the size and rotation overshoot slightly on the way out and on the way back. Other named constants you can swap in are Spring.DampingRatioNoBouncy, Spring.DampingRatioLowBouncy, and Spring.DampingRatioHighBouncy.

Both constants feed into a single spring(...) call that becomes the animationSpec for every channel:

val size by animateDpAsState(
  targetValue = if (morphed) expandedSize else collapsedSize,
  animationSpec = spring(dampingRatio = springDamping, stiffness = springStiffness),
  label = "size",
)

The label argument is purely for tooling. It shows up in the Animation Preview inspector so you can identify which spring belongs to which property when you scrub the timeline. None of the four animations declares a visibilityThreshold, so each one falls back to the type appropriate default that Compose ships, which is enough precision to know when to stop emitting frames.

Driving multiple properties from one state

The reason this example feels coordinated rather than chaotic is that all four animateAsState calls observe the same boolean. Flipping morphed invalidates every reader in the same recomposition, and each spring starts a new animation toward its own target on the same frame. There is no orchestrator, no shared Animatable, and no updateTransition. Each value is independent at the API level but synchronized at the source.

val rotation by animateFloatAsState(
  targetValue = if (morphed) expandedRotation else collapsedRotation,
  animationSpec = spring(dampingRatio = springDamping, stiffness = springStiffness),
  label = "rotation",
)
val cornerDp by animateDpAsState(
  targetValue = if (morphed) expandedCornerDp else collapsedSize / 2,
  animationSpec = spring(dampingRatio = springDamping, stiffness = springStiffness),
  label = "corner",
)

Notice how the corner radius collapses to collapsedSize / 2, which equals 37.dp when the FAB is 74.dp wide. Half of the side length is the radius that turns a square RoundedCornerShape into a circle, so the idle state is a perfect disc and the expanded state is a 24.dp rounded rectangle. The same spring spec governs both, which means the disc to rectangle transition shares the same overshoot signature as the size and rotation, and the eye reads it as one motion.

The fourth channel, color, uses animateColorAsState and the same spring(...) spec. Spring physics on a color works because Compose interpolates in a continuous color space and treats the components as floats. The result is that the hue shift overshoots in lockstep with the geometry, and you see a brief moment where the color is past its target tint while the shape is still finding its corner radius.

Tweaking stiffness, damping, and targets

The example exposes four kinds of constants you can tune. Each one changes a different axis of the motion feel, and the source comments call out drop in alternatives.

For stiffness, raising it from Spring.StiffnessMediumLow to Spring.StiffnessMedium or Spring.StiffnessHigh shortens the time the spring spends in motion. The morph snaps into place faster, the overshoot peak still happens but it occurs sooner and decays sooner. Dropping to Spring.StiffnessLow does the opposite. The motion stretches out, and you can almost watch the spring negotiate with the target, which suits a hero element but feels slow for a frequent action.

For damping, swapping Spring.DampingRatioMediumBouncy for Spring.DampingRatioNoBouncy removes the wobble entirely, so the FAB grows to its expanded size and stops without crossing the target. Spring.DampingRatioLowBouncy keeps a hint of overshoot, while Spring.DampingRatioHighBouncy exaggerates it so the FAB ripples past the target two or three times before settling. Lower damping pairs well with playful affordances and badly with surfaces the user reads text on.

For target sizes, the gap between collapsedSize = 74.dp and expandedSize = 196.dp is large enough that the wobble is visible. If you narrow the gap to something like 120.dp, the spring still overshoots but the visual delta shrinks and the motion reads as a confirmation rather than a transformation. The corner radius target works the same way. Setting expandedCornerDp close to collapsedSize / 2 keeps the shape circular throughout, which can be useful when you only want the size and color to change.

For rotation, the source sets expandedRotation = 135f, which lands the + glyph at a soft diagonal that reads almost like an x without committing to one. The comment suggests 45f, 180f, and 360f as alternatives. A 180f flip feels like a state inversion, while 360f lets the same spring carry the icon all the way around for a flourish, and the bouncy damping makes the final degrees rock back and forth before settling.

Conclusion

In this article, you've walked through AnimationExample4, a floating action button that morphs across four properties at once. You saw how a single morphed boolean drives independent animateDpAsState, animateFloatAsState, animateColorAsState, and corner radius animations, how each one uses the same spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMediumLow) spec, and how that shared spec is what makes the four channels read as one cohesive motion.

Understanding what stiffness and damping ratio actually control helps you reach for the right knob when the motion does not feel right. If a transition feels lazy, raise stiffness before reaching for a shorter duration. If a transition feels nervous, raise the damping ratio toward 1f before reducing the size delta. If the values overshoot in a way that breaks the affordance, swap the damping constant rather than abandoning the spring spec, because tween based curves give up the interruption behavior that springs handle for free.

Whether you are building a FAB that confirms an action, a card that expands into a detail surface, or an icon that toggles between two states, the same animateAsState plus spring pattern carries the work. Pick named constants from the Spring object first, lean on shared specs across related properties to keep motion synchronized, and tune target values rather than swapping animation systems when the look is close but not quite there.

As always, happy coding!

Jaewoong (skydoves)