Play / Pause Morph

A play triangle that morphs into a pause pair through Path interpolation on a Canvas. The shape itself is the animation.

Compose APIs
AnimatablePathCanvaslerp
Try tweaking
MORPH_DURATION_MSsource and target path coordinatesfill colors

Most play and pause toggles cheat. They hold two PNGs in memory, fade one out, and fade the other in. The transition reads as a swap because that is what it is, two sprites traded behind a crossfade. A real morph treats the icon as geometry. The triangle does not vanish, it walks. Each corner slides to a new position, and the shape you see at frame 17 is a quadrilateral that belongs to neither the play nor the pause state.

In this article, you'll explore how AnimationExample11 builds a continuous play to pause morph using Animatable driven animateFloatAsState, a single Canvas, two parallel point lists, and per vertex lerp. You'll see why the path morph requires matching vertex counts, how one fraction drives shape, color, and timing together, and which constants you can tweak to change the feel.

How the example is structured

The composable holds three pieces of state. A Boolean isPlaying decides which side of the morph to target. A morphProgress float, produced by animateFloatAsState, interpolates between 0f and 1f over MORPH_DURATION_MS. A bgColor runs in parallel through animateColorAsState, swapping the circle background between PLAY_COLOR and PAUSE_COLOR.

The visible surface is a single Box sized at ICON_BOX_DP.dp with a CircleShape background. Tapping the box flips isPlaying, which flips both target values. Inside the box sits a Canvas half the diameter of the box, and that Canvas draws two Path shapes whose corners move with morphProgress.

var isPlaying by remember { mutableStateOf(false) }
val morphProgress by animateFloatAsState(
  targetValue = if (isPlaying) 1f else 0f,
  animationSpec = tween(durationMillis = MORPH_DURATION_MS),
  label = "morph",
)

Notice the structure: the Canvas itself never animates. Compose recomposes the draw lambda each time morphProgress changes, and the lambda recomputes both paths from scratch. The animation lives entirely in the float, not in the geometry.

Defining play and pause as parallel point lists

The morph treats every corner as a coordinate that exists in both the start and end state. The Canvas derives its base measurements from size.width, then expresses each corner as a fraction of that width.

val triHalf = w * 0.32f
val barHalfW = w * 0.12f
val barGap = w * 0.18f

triHalf is the half height of the play triangle. barHalfW is the half width of each pause bar. barGap is the horizontal distance from the icon center to the center of each bar. All three scale with the Canvas width, which means the icon stays proportional regardless of the parent size.

The play triangle has three logical corners, but the morph builds its shape as a quadrilateral with four vertices.

val triTopX = cx - triHalf * 0.65f
val triTopY = cy - triHalf
val triBotX = cx - triHalf * 0.65f
val triBotY = cy + triHalf
val triRightX = cx + triHalf
val triRightY = cy

The top and bottom corners share the same x, offset to the left of center by triHalf * 0.65f. The right corner sits at the vertical center. The left bar of the pause icon is a true rectangle, four corners pinned by leftBarL, leftBarR, barTop, barBot.

val leftBarL = cx - barGap - barHalfW
val leftBarR = cx - barGap + barHalfW
val barTop = cy - triHalf
val barBot = cy + triHalf

Path morphs only work when the two shapes share three properties. The vertex count must match, otherwise there is no destination for an extra corner to slide toward. The winding order must match, otherwise the polygon flips inside out partway through. And the 1:1 correspondence between vertices must be meaningful, otherwise corners cross over each other and produce a tangled shape mid morph. The example reuses the right edge of the triangle twice, mapping the top right and bottom right of the triangle to the top and bottom of the left pause bar. That gives a four to four mapping that morphs cleanly.

Linear interpolation per vertex

Once both shapes share a vertex layout, the morph is mechanical. For each frame, every vertex reads the current progress value and computes its position as a weighted blend of its start and end coordinates.

private fun lerp(start: Float, end: Float, fraction: Float): Float =
  start + (end - start) * fraction

lerp is linear interpolation. At fraction = 0f it returns start. At fraction = 1f it returns end. At 0.5f it returns the midpoint. The draw lambda calls lerp twice per vertex, once for x and once for y, then feeds the results to moveTo or lineTo.

val leftPath = Path().apply {
  moveTo(lerp(triTopX, leftBarL, progress), lerp(triTopY, barTop, progress))
  lineTo(lerp(triRightX, leftBarR, progress), lerp(triRightY, barTop, progress))
  lineTo(lerp(triRightX, leftBarR, progress), lerp(triRightY, barBot, progress))
  lineTo(lerp(triBotX, leftBarL, progress), lerp(triBotY, barBot, progress))
  close()
}

Each line of the Path builder defines one corner that walks from a triangle position to a pause bar position. At progress = 0f the four lerp calls collapse onto the triangle corners, and the path renders as the play triangle. At progress = 1f they collapse onto the pause bar corners, and the path renders as the left bar. Every value in between is a real quadrilateral, no morph engine, no animation library, no path measurer required.

The right pause bar is a special case. Both source coordinates of every vertex point at triRightX, triRightY, the right tip of the triangle. That means at progress = 0f all four corners collapse to a single point and the bar has zero area. As progress rises, the corners spread out toward rightBarL, rightBarR, barTop, barBot, and the bar grows out of the triangle's right tip.

A single fraction drives everything

The morph runs on one source of truth. animateFloatAsState returns a state holder whose value Compose smoothly drives between 0f and 1f whenever the target flips. Every animated property in the example reads from that one number, either directly or through a parallel state holder fed by the same boolean.

val morphProgress by animateFloatAsState(
  targetValue = if (isPlaying) 1f else 0f,
  animationSpec = tween(durationMillis = MORPH_DURATION_MS),
  label = "morph",
)

morphProgress is consumed inside the Canvas for vertex lerp. bgColor runs through animateColorAsState with the same MORPH_DURATION_MS, so the background tween finishes on the same frame the geometry settles. If you wanted to scale the icon during the morph, you could multiply by morphProgress inside the draw lambda. If you wanted to fade in a label, you could pass morphProgress to a Modifier.alpha.

The pattern is the strength here. One animated float, many derived properties. The geometry, the color, and any future animated property stay in lockstep because they all read from the same fraction.

Tweaking duration, coordinates, colors

MORPH_DURATION_MS is set to 500. That is half a second from triangle to pause and back. Drop it to 200 and the morph snaps, useful for a touch responsive feel where the icon should keep up with rapid taps. Raise it to 1200 and the morph crawls, useful for a marketing demo where the shape interpolation itself is the point. Because the same duration drives both the float and the color, the two stay in sync without extra coordination.

The source and target coordinates control the path each corner takes. triHalf * 0.65f pulls the left side of the triangle inward, giving it the asymmetric look of a typical play glyph. Bump that multiplier to 1.0f and the triangle becomes equilateral. Reduce barGap and the two pause bars sit closer together. Increase barHalfW and the bars become heavier rectangles. Each of these changes the resting shape, but the morph between them remains a straight line per vertex because the math has no opinion about where the points start or end.

PLAY_COLOR is Color\(0xFFDE2263\), a saturated pink. PAUSE_COLOR is Color(0xFF42A5F5), a calm blue. The color shift carries semantic weight, warm and active for play, cool and held for pause. The path itself is drawn in Color.White over the colored circle, so the morph reads as one icon over a shifting background. Swap the path color for a darker tone and the icon becomes a cutout against the background. Swap the background colors for two values closer in luminance and the morph quiets down, putting the geometry at the center of attention.

Conclusion

In this article, you've explored how a play to pause morph can be expressed as four points walking from one polygon to another, with lerp doing all the math and a single animateFloatAsState driving every animated property. The Canvas does not know what a play icon is. It knows about quadrilaterals whose corners happen to land on triangle positions at one end and rectangle positions at the other.

Understanding this approach helps you treat icon transitions as geometry instead of asset swaps. The constraint that vertex counts must match across the morph is what forces clean thinking about the design. Once you accept that both shapes need the same skeleton, you start picking icons that share a structural mapping rather than icons that look right on their own.

Whether you're building a media control, an expand or collapse affordance, or a state indicator that shifts meaning over time, this pattern provides the foundation for icon transitions that feel native to the surface they live on. The icon stops being a picture and becomes a function of state, redrawn every frame from a single fraction.

As always, happy coding!

Jaewoong (skydoves)