Skip to content

Shape morphing with MaterialShapes in Jetpack Compose

Short answer: call .toShape() on any MaterialShapes polygon to use it as a normal Compose Shape. To animate between two shapes, create a Morph(start, end), wrap it in a small Shape that calls morph.toPath(progress), and animate progress with the theme’s motion scheme.

@Composable
fun CookieBadge() {
Box(
Modifier
.size(64.dp)
.background(MaterialTheme.colorScheme.primary, MaterialShapes.Cookie9Sided.toShape()),
)
}

MaterialShapes polygons are normalized to a 0..1 box, so toShape() scales them to whatever size the layout gives.

Compose has no built-in “morphing shape”, but it takes very little code:

MorphShape.kt
/** Draws [morph] at [progress] (0 = start polygon, 1 = end polygon), scaled to the layout size. */
class MorphShape(private val morph: Morph, private val progress: Float) : Shape {
override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline {
val path = morph.toPath(progress = progress, path = Path())
path.transform(Matrix().apply { scale(size.width, size.height) })
return Outline.Generic(path)
}
}

Then animate the progress. With MotionScheme.expressive() the spatial spec is a spring, so the shape overshoots slightly, which is the Expressive feel:

@Composable
fun MorphOnTap() {
var selected by remember { mutableStateOf(false) }
val morph = remember { Morph(MaterialShapes.Circle, MaterialShapes.Cookie9Sided) }
val progress by animateFloatAsState(
targetValue = if (selected) 1f else 0f,
animationSpec = MaterialTheme.motionScheme.defaultSpatialSpec(), // springy with MotionScheme.expressive()
label = "morph",
)
Box(
Modifier
.size(96.dp)
.background(MaterialTheme.colorScheme.primary, MorphShape(morph, progress))
.clickable { selected = !selected },
)
}

M3 Expressive Paywall uses this in two places: the selection radio morphs from a circle into Cookie9Sided, and the plan picker spotlight card morphs between a cookie, a sunny shape and a flower as you switch plans.

Plan picker paywall with a morphing spotlight card, light theme
Light
Plan picker paywall with a morphing spotlight card, dark theme
Dark
  • Morph between shapes with similar complexity. Circle to cookie looks smooth; circle to heart can twist.
  • Rotate the morphed shape slightly with graphicsLayer { rotationZ = … } for more motion.
  • Respect reduced motion: with MotionScheme.standard() the springs are calmer.

FAQ

Which shapes are available?

MaterialShapes includes Circle, Square, Pill, Cookie4Sided to Cookie12Sided, Sunny, VerySunny, Flower, Clover4Leaf, Clover8Leaf, Burst, SoftBurst, Heart, Gem and more. They are RoundedPolygon values from androidx.graphics.shapes.

Do I need the graphics-shapes dependency?

material3 brings it transitively. Add androidx.graphics:graphics-shapes explicitly if you use Morph or RoundedPolygon directly, as this guide does.

Why does my morph restart on every recomposition?

The Morph or its polygons are created again on each recomposition. Wrap the Morph in remember, and keep target shapes as stable instances.

Try it in your app

Add the library (JitPack, minSdk 26), then follow Getting started.

implementation("com.github.sanikantv:m3-expressive-paywall:v1.0.0")
Get started

⭐ If this guide helped, star M3 Expressive Paywall on GitHub. It helps other Compose developers find it.

Built by Sanikant. Set in Google Sans Flex (SIL Open Font License). Not affiliated with Google.