Easing Showcase

Six runners race side by side, each using a different Easing. Linear, ease in, ease out, fast out slow in, anticipate, overshoot, side by side.

Compose APIs
animateFloatAsStateEasingtween
Try tweaking
any Easing function in the entries listRACE_DURATION_MS

Duration tells an animation how long to run. Easing tells it how to feel. The same two and a half second tween can land like a metronome, drift in like a sigh, or settle with a small bounce, and the only thing that changed is the curve mapping elapsed time to visual progress. Easing is a pure function from a normalized 0..1 fraction of time to a 0..1 fraction of distance, and that single function controls almost everything you perceive as character in motion. In AnimationExample10, six runners race the same distance in the same duration, each driven by a different Easing, so the differences are the easings and nothing else.

In this article, you'll explore how the side by side race is wired with animateFloatAsState and tween, what the Easing interface actually is, how the built in easings shape the curve, how CubicBezierEasing lets you design your own, and how tweaking the lineup or RACE_DURATION_MS changes the perceived motion.

How the example is structured

The screen is a vertical stack of six identical lanes. Each lane is a RaceTrack that owns its own animateFloatAsState, all of them targeting the same shared progress value. When the button toggles progress between 0f and 1f, every lane starts a fresh tween from its current value to the new target. The tween duration is the same for every runner, only the easing argument differs:

val animated by animateFloatAsState(
  targetValue = progress,
  animationSpec = tween(
    durationMillis = durationMs,
    easing = entry.easing,
  ),
  label = entry.name,
)

Because all six animations are launched in the same frame against the same target, they form a controlled experiment. The horizontal offset of each runner is trackWidthPx * animated, so the runner's x position is a direct readout of the easing's output for the current elapsed fraction. If a runner is ahead of another at any instant, that is exactly the difference between the two easing curves at that moment in time.

There is also a small driver trick at the top of the composable. A second animateFloatAsState runs against progress with LinearEasing, and its value is read into a throwaway local. This keeps the parent composable subscribed to the animation clock so the lanes stay in sync even as they recompose individually. The simulation is otherwise stateless: the source of truth is the single progress float.

What Easing actually is

Strip away the names and the curves, and Easing is one of the smallest interfaces in Compose:

fun interface Easing {
  fun transform(fraction: Float): Float
}

The fraction is the normalized elapsed time of the animation. At the very first frame after start, it is close to 0f. At the last frame, it is 1f. The transform return value is the normalized progress the animation system uses to interpolate between the start and end values. A linear easing returns the input unchanged, so 50 percent elapsed equals 50 percent moved. An ease out returns a value greater than the input early in the animation, so the runner sprints out ahead and slows down toward the finish. An ease in does the opposite, hanging back early and accelerating into the end.

Two consequences fall out of this shape. First, easing is independent of duration. Doubling RACE_DURATION_MS does not change the curve, only how long the curve is sampled. Second, the curve is what people actually perceive as personality in motion. Material's standard easings exist because the team chose specific curves that read as natural to a human eye, and you can hear the difference even before you see it.

The built in easings, lined up

The example pulls six entries straight from androidx.compose.animation.core:

val entries = listOf(
  EasingEntry("LinearEasing", LinearEasing, Color(0xFFEF5350)),
  EasingEntry("FastOutSlowInEasing", FastOutSlowInEasing, Color(0xFFAB47BC)),
  EasingEntry("FastOutLinearInEasing", FastOutLinearInEasing, Color(0xFF42A5F5)),
  EasingEntry("LinearOutSlowInEasing", LinearOutSlowInEasing, Color(0xFF26A69A)),
  EasingEntry("EaseInOutCubic", EaseInOutCubic, Color(0xFFFFA726)),
  EasingEntry("EaseOutBounce", EaseOutBounce, Color(0xFF8D6E63)),
)

LinearEasing is the control runner. Its transform returns the input unchanged, so the red dot moves at constant speed across the track. It is rarely the right choice for UI because nothing in the physical world starts and stops instantly, but it is the reference everyone else is measured against.

FastOutSlowInEasing is the Material standard easing for objects moving across the screen and changing position. It accelerates quickly out of rest and decelerates slowly into the destination, so it leaves the start before linear and arrives noticeably softer. In the race, the purple runner pulls ahead in the middle and then settles into the finish.

FastOutLinearInEasing is the entering variant. It starts with the same fast acceleration, then continues at a constant rate to the end. Use it when an element is leaving the screen, since the snappy start sells the exit and a soft landing is unnecessary. The blue runner pulls ahead early then runs flat to the finish line.

LinearOutSlowInEasing is the symmetric partner. It begins linearly, then decelerates into the end. Use it for elements entering the screen, where the soft landing is what you want the eye to notice. The teal runner stays close to the red linear runner early, then drifts in last.

EaseInOutCubic is a smooth cubic that eases on both ends. It is symmetric, slower out of rest than FastOutSlowInEasing, and slightly more relaxed into the end. The orange runner trails the purple runner through the middle of the race and lands at the same time.

EaseOutBounce is the personality pick. It overshoots and bobs at the end, simulating a ball settling on a surface. The brown runner reaches the finish quickly, then bounces back and forth a few times before coming to rest. It is fun in small doses and overwhelming on every animation, which is exactly why the example pairs it with five calmer curves so you can see the contrast.

Building your own with CubicBezierEasing

The named easings above are mostly thin wrappers around CubicBezierEasing, the workhorse that lets you design any feel you want. The constructor takes four floats:

val anticipate = CubicBezierEasing(0.6f, -0.28f, 0.74f, 0.05f)
val overshoot  = CubicBezierEasing(0.18f, 0.89f, 0.32f, 1.28f)

Those four numbers are the x and y coordinates of two control points for a cubic Bezier curve in the unit square. The endpoints are fixed at (0, 0) and (1, 1), so the curve always starts at zero progress and finishes at full progress. The first pair, (0.6, -0.28), pulls the start of the curve below the x axis, which produces an anticipate: the runner backs up slightly before launching forward. The second pair, (0.32, 1.28), pushes the end of the curve above one, which produces an overshoot: the runner crosses the finish line and pulls back.

If you want to swap any built in for a custom feel, drop a new EasingEntry into the list with a CubicBezierEasing of your choosing. The race rig is identical for every lane, so a new curve becomes a new runner with no other plumbing.

Tweaking the lineup and the duration

Two knobs change everything you see. The entries list controls who races. Add or remove an EasingEntry and the column rebuilds with one more or one fewer lane, all driven by the same shared progress. Swap EaseOutBounce for an aggressive overshoot and the brown lane suddenly behaves like a fired projectile. Because the lane component does not know which easing it received, every entry is a one line change.

RACE_DURATION_MS controls how long the curves are stretched in time. The comment in the source calls out that a longer duration makes easings easier to compare, and that is exactly right. At 250 milliseconds the differences between FastOutSlowInEasing and EaseInOutCubic blur into a single quick blip. At 2500 milliseconds the same two curves feel clearly distinct, with the Material easing arriving sooner and EaseInOutCubic lingering. Easing is independent of duration, but perception is not. Pick a duration long enough that the curve has room to do its work, then trim it as small as you can while keeping the character intact.

A useful exercise is to set RACE_DURATION_MS to something extreme, watch the bounce easing thrash for several seconds, then back it down to a normal UI duration like 300 milliseconds. The shape of the curve has not changed, but its expression has gone from cartoon to subtle accent.

Conclusion

In this article, you've explored the easing dimension of Compose animations through a side by side race rig. Each lane is a single animateFloatAsState driving a position offset, with the tween easing argument as the only difference between runners. The Easing interface itself is a one method function from a normalized elapsed fraction to a normalized progress fraction, and that small contract is enough to express linear, accelerating, decelerating, symmetric, and bouncing motion.

Understanding easings as curves rather than presets helps you reason about motion the way the design system does. FastOutSlowInEasing, FastOutLinearInEasing, and LinearOutSlowInEasing are not arbitrary names, they encode where each curve spends its time. When you reach for CubicBezierEasing, you are not picking from a menu, you are placing two control points to shape the feel you want.

Whether you are tuning a button press, choreographing a screen transition, or designing a playful brand moment, the same loop applies: pick a duration that gives the motion room to breathe, choose or design an easing that matches the role of the element, and trust the curve to do the rest. The race rig in AnimationExample10 is the fastest way to build that intuition, because every comparison is one entry in a list away.

As always, happy coding!

Jaewoong (skydoves)