Recently, Google announced the next iteration of its Material Design language – The Material 3 Expressive.
As Google explains, they want to bring personality and emotion to otherwise boring app UIs, helping users feel more connected to their smartphones — which, according to Google, are no longer just simple tools, but extensions of ourselves.
With the new version of Material 3 Expressive, Google introduces a new motion-physics system designed to make app UIs feel more alive, fluid, and natural.
As I explore the capabilities of Material 3 Expressive, one of the first components I’m implementing is button toggles. With the new expressive motion and visual system, I’m excited to see how these toggles can feel more dynamic, fluid, and emotionally engaging — moving beyond static UI elements to something that truly responds to user interaction.
Let’s start!
First, Let’s create 3 buttons as in the Google’s preview, but let’s do it step by step.
var selectedIndex by remember { mutableIntStateOf(-1) } val selectedColor = Color(0xFF554F6E) val unselectedColor = Color(0xFFEAE5FF) val icons = listOf(Icons.Outlined.Alarm, Icons.Outlined.LinkOff, Icons.Outlined.Wifi) val iconSize = 60.dp Row(Modifier.padding(horizontal = 8.dp),) { icons.forEachIndexed { index, icon -> FloatingActionButton( modifier = Modifier .padding(4.dp) .width( when (index) { 1 -> iconSize / 1.5f 2 -> iconSize * 1.5f else -> iconSize } ) .height(iconSize), elevation = FloatingActionButtonDefaults.elevation( defaultElevation = 0.dp, pressedElevation = 0.dp ), containerColor = if (selectedIndex == index) { selectedColor } else { unselectedColor }, contentColor = if (selectedIndex == index) { unselectedColor } else { selectedColor }, onClick = { selectedIndex = index }, ) { Icon( imageVector = icon, contentDescription = null, ) } } }
This is what we’ll get as a result. Let’s make this match the Google’s example with changing it’s sizes by just using regular Floating Action Button size.

Image from code example above
We have all we need now and there’s only one thing which is missing. Let’s add some animations and make it alive 🪄.
@Composable | |
fun ExpressiveButtonAnimation() { | |
var selectedIndex by remember { mutableIntStateOf(-1) } | |
val checkedColor = Color(0xFF554F6E) | |
val uncheckedColor = Color(0xFFEAE5FF) | |
val icons = listOf(Icons.Outlined.Alarm, Icons.Outlined.LinkOff, Icons.Outlined.Wifi) | |
var weights = remember { mutableStateListOf(0.85f, 0.65f, 1.5f) } | |
Box( | |
modifier = Modifier | |
.fillMaxSize() | |
.background(Color(0xFFDAD2FF)), | |
contentAlignment = Alignment.Center, | |
) { | |
Row( | |
modifier = Modifier | |
.fillMaxWidth(0.6f) | |
.padding(horizontal = 8.dp) | |
) { | |
icons.forEachIndexed { index, icon -> | |
ExpressiveFloatingActionButton( | |
icon = icon, | |
itemWeight = weights[index], | |
checked = selectedIndex == index, | |
checkedColor = checkedColor, | |
uncheckedColor = uncheckedColor, | |
onClick = { | |
selectedIndex = if (index == selectedIndex) { | |
-1 | |
} else { | |
index | |
} | |
} | |
) | |
} | |
} | |
} | |
} | |
@Composable | |
fun RowScope.ExpressiveFloatingActionButton( | |
icon: ImageVector, | |
itemWeight: Float, | |
checked: Boolean, | |
checkedColor: Color, | |
uncheckedColor: Color, | |
onClick: () -> Unit, | |
) { | |
var shapeSelected by remember { mutableStateOf(false) } | |
val animatedRadius by animateDpAsState( | |
targetValue = if (shapeSelected) 6.dp else 16.dp, | |
label = "animatedRadius" | |
) | |
val animatedWeight by animateFloatAsState( | |
targetValue = if (shapeSelected) 0.25f else 0f | |
) | |
val iconSize = 70.dp | |
IconButton( | |
modifier = Modifier | |
.padding(4.dp) | |
.weight(itemWeight + animatedWeight) | |
.height(iconSize) | |
.pointerInput(Unit) { | |
awaitPointerEventScope { | |
while (true) { | |
val event = awaitPointerEvent(PointerEventPass.Main) | |
event.changes.forEach { pointerInputChange -> | |
shapeSelected = pointerInputChange.pressed | |
} | |
} | |
} | |
}, | |
colors = IconButtonDefaults.filledIconButtonColors( | |
containerColor = if (shapeSelected || checked) checkedColor else uncheckedColor, | |
contentColor = if (shapeSelected || checked) uncheckedColor else checkedColor, | |
), | |
shape = MaterialTheme.shapes.large.copy(CornerSize(animatedRadius)), | |
onClick = onClick, | |
) { | |
Icon( | |
imageVector = icon, | |
contentDescription = null, | |
) | |
} | |
} |
I have changed the fixed item width with weights, so the UI will have same proportions on all screen sizes.
And here is the final result:
Job Offers
If you’d rather not deal with the custom animations manually, you can use ButtonGroup, which already supports all the new animations. However, keep in mind that it’s currently in alpha — meaning the API is still unstable and subject to change in future releases — and its functionality is still quite limited.
You can find more about M3 Expressive here.
If you liked this article, you can check my other articles and some of my open source projects.
This article was previously published on proandroiddev.com.