Sitemap

Express Yourself: Designing with Material 3 Expressive in Compose

Nav Singh
7 min readMay 16, 2025

Greetings, fellow Android developers! Today, we will learn about Material 3 Expressive, Google’s new design system, which was announced at the Android show.

Since its launch in 2014, M3 Expressive is the most researched update to the design system. Extensive user research — 46 studies with more than 18,000 participants.

🌟 Let’s take a look at the new components and see how to implement them with Jetpack Compose. 🌟

  • Loading indicator
  • Split button
  • FAB menu
  • Button groups
  • Toolbars

First things first

  • All these new components are available from the version 1.4.0-alpha14 of Material3
  • Add the following dependency to the build.gradle file
implementation("androidx.compose.material3:material3-android:1.4.0-alpha14")

LoadingIndicator


@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Preview(showBackground = true)
@Composable
fun LoadingIndicator() {
Column(
Modifier
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally

) {

// Default
LoadingIndicator()

// With 2 shapes only
LoadingIndicator(
polygons = LoadingIndicatorDefaults.IndeterminateIndicatorPolygons.take(2)
)

// Default
ContainedLoadingIndicator()

// Custom Container Color
ContainedLoadingIndicator(
containerColor = Color.Cyan
)
}
}
Loading Ind

Split Button

  • LeadingButton — We can use a custom composable or construct a SplitButtonDefaults.LeadingButton
  • TrailingButton — We can use a custom composable or construct a SplitButtonDefaults.TrailingButton
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@Preview(showBackground = true)
fun FilledSplitButton() {
var checked by remember { mutableStateOf(false) }
SplitButtonLayout(
leadingButton = {
SplitButtonDefaults.LeadingButton(
onClick = { /* */ },
) {
Icon(
Icons.Filled.Edit,
modifier = Modifier.size(SplitButtonDefaults.LeadingIconSize),
contentDescription = "",
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text("My Button")
}
},
trailingButton = {
SplitButtonDefaults.TrailingButton(
checked = checked,
onCheckedChange = { checked = it },
) {
Icon(
Icons.Filled.KeyboardArrowDown,
contentDescription = ""
)
}
}
)
}

ElevatedButton

@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@Preview(showBackground = true)
fun ElevatedSplitButton() {
var checked by remember { mutableStateOf(false) }

SplitButtonLayout(
leadingButton = {
SplitButtonDefaults.ElevatedLeadingButton(
onClick = { /* */ },
) {
Icon(
Icons.Filled.Edit,
modifier = Modifier.size(SplitButtonDefaults.LeadingIconSize),
contentDescription = "",
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text("My Button")
}
},
trailingButton = {
SplitButtonDefaults.ElevatedTrailingButton(
checked = checked,
onCheckedChange = { checked = it },
) {
Icon(
Icons.Filled.KeyboardArrowDown,
contentDescription = ""
)
}
})
}

OutlinedButton + Custom Spacing

@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@Preview(showBackground = true)
fun OutlinedSplitButton() {

var checked by remember { mutableStateOf(false) }

SplitButtonLayout(
// Custom Spacing
spacing = 8.dp,
leadingButton = {
SplitButtonDefaults.OutlinedLeadingButton(
onClick = { /* */ },
) {
Icon(
Icons.Filled.Edit,
modifier = Modifier.size(SplitButtonDefaults.LeadingIconSize),
contentDescription = "",
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text("My Button")
}
},
trailingButton = {
SplitButtonDefaults.OutlinedTrailingButton(
checked = checked,
onCheckedChange = { checked = it },
) {
// Rotate the icon based on the check value
val rotation: Float by
animateFloatAsState(
targetValue = if (checked) 180f else 0f,
label = "Trailing Icon Rotation"
)
Icon(
Icons.Filled.KeyboardArrowDown,
modifier =
Modifier
.size(SplitButtonDefaults.TrailingIconSize)
.graphicsLayer {
this.rotationZ = rotation
},
contentDescription = ""
)
}
})
}

FloatingActionButtonMenu

This needs to be used with ToggleFloatingActionButton

We can replace any use of stacked small FABs with this new component

@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Preview(showBackground = true)
@Composable
fun FloatingActionButtonMenu() {
val listState = rememberLazyListState()
val fabVisible by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }

Box {
LazyColumn(state = listState) {
for (index in 0 until 5) {
item {
Text(
text = "Item - $index",
modifier = Modifier.fillMaxWidth().padding(24.dp)
)
}
}
}

val items =
listOf(
Icons.Filled.Snooze to "Snooze",
Icons.Filled.Archive to "Archive",
Icons.AutoMirrored.Filled.Label to "Label",
)

var fabMenuExpanded by rememberSaveable { mutableStateOf(false) }

FloatingActionButtonMenu(
modifier = Modifier.align(Alignment.BottomEnd),
expanded = fabMenuExpanded,
button = {
ToggleFloatingActionButton(
modifier =
Modifier
.animateFloatingActionButton(
visible = fabVisible || fabMenuExpanded,
alignment = Alignment.BottomEnd
),
checked = fabMenuExpanded,
// Large size
containerSize = ToggleFloatingActionButtonDefaults.containerSizeLarge(),
onCheckedChange = { fabMenuExpanded = !fabMenuExpanded }
) {
val imageVector by remember {
derivedStateOf {
// checkedProgress - provides the value of button's state that we use here to update th icon
if (checkedProgress > 0.5f) Icons.Filled.Close else Icons.Filled.Add
}
}
Icon(
painter = rememberVectorPainter(imageVector),
contentDescription = null,
modifier = Modifier.animateIcon({ checkedProgress })
)
}
}
) {
items.forEachIndexed { i, item ->
FloatingActionButtonMenuItem(
onClick = { fabMenuExpanded = false },
icon = { Icon(item.first, contentDescription = null) },
text = { Text(text = item.second) },
)
}
}
}
}

ButtonGroup

🌅 The segmented button has been deprecated in favor of connected button groups.

@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Preview(showBackground = true)
@Composable
fun ButtonGroup() {
val numButtons = 10
Column (modifier = Modifier.height(100.dp)){
ButtonGroup(
overflowIndicator = { menuState ->
FilledIconButton(
onClick = {
if (menuState.isExpanded) {
menuState.dismiss()
} else {
menuState.show()
}
}
) {
Icon(
imageVector = Icons.Filled.MoreVert,
contentDescription = ""
)
}
}
) {
for (i in 0 until numButtons) {
clickableItem(onClick = {}, label = "$i")
}
}
}
}

SingleSelectConnectedButtonGroup


@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@Preview(showBackground = true)
fun SingleSelectConnectedButtonGroup() {
val options = listOf("Work", "Restaurant", "Coffee")
val unCheckedIcons =
listOf(Icons.Outlined.Work, Icons.Outlined.Restaurant, Icons.Outlined.Coffee)
val checkedIcons = listOf(Icons.Filled.Work, Icons.Filled.Restaurant, Icons.Filled.Coffee)
var selectedIndex by remember { mutableIntStateOf(0) }

Row(
Modifier.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween),
) {

options.forEachIndexed { index, label ->
ToggleButton(
checked = selectedIndex == index,
onCheckedChange = { selectedIndex = index }
) {
Icon(
if (selectedIndex == index) checkedIcons[index] else unCheckedIcons[index],
contentDescription = ""
)
Spacer(Modifier.size(ToggleButtonDefaults.IconSpacing))
Text(label)
}
}
}
}

MultiSelectConnectedButtonGroup + Custom shapes

@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Preview(showBackground = true)
@Composable
fun MultiSelectConnectedButtonGroup() {
val options = listOf("Work", "Restaurant", "Coffee")
val unCheckedIcons =
listOf(Icons.Outlined.Work, Icons.Outlined.Restaurant, Icons.Outlined.Coffee)
val checkedIcons = listOf(Icons.Filled.Work, Icons.Filled.Restaurant, Icons.Filled.Coffee)
val checked = remember { mutableStateListOf(false, false, false) }

Row(
Modifier.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween)
) {
options.forEachIndexed { index, label ->
ToggleButton(
checked = checked[index],
onCheckedChange = { checked[index] = it },
// Custom shapes
shapes =
when (index) {
0 -> ButtonGroupDefaults.connectedLeadingButtonShapes()
options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes()
else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
}
) {
Icon(
if (checked[index]) checkedIcons[index] else unCheckedIcons[index],
contentDescription = ""
)
Spacer(Modifier.size(ToggleButtonDefaults.IconSpacing))
Text(label)
}
}
}
}

Toolbar

Two expressive types

  1. Docked toolbar
  2. Floating toolbar

🌅 Bottom app bar is being deprecated and should be replaced with the docked toolbar

VerticalFloatingToolbar

@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Preview
@Composable
fun OverflowingVerticalFloatingToolbarSample() {
Scaffold(
content = { innerPadding ->
Box(Modifier.padding(innerPadding)) {
// Content
LazyColumn(
state = rememberLazyListState(),
contentPadding = innerPadding,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
val list = (0..25).map { it.toString() }
items(count = list.size) {
Text(text = list[it])
}
}

// VerticalFloatingToolbar
VerticalFloatingToolbar(
modifier = Modifier
.align(Alignment.BottomEnd)
.offset(x = -ScreenOffset),
expanded = true,
leadingContent = { },
trailingContent = {
AppBarColumn(
overflowIndicator = { menuState ->
IconButton(
onClick = {
if (menuState.isExpanded) {
menuState.dismiss()
} else {
menuState.show()
}
}
) {
Icon(
imageVector = Icons.Filled.MoreVert,
contentDescription = ""
)
}
}
) {
clickableItem(
onClick = { /* */ },
icon = {
Icon(
Icons.Filled.Download,
contentDescription = ""
)
},
label = "Download"
)

clickableItem(
onClick = { /* */ },
icon = {
Icon(
Icons.Filled.Person,
contentDescription = ""
)
},
label = "Person"
)

}
},
content = {
FilledIconButton(
modifier = Modifier.height(64.dp),
onClick = { /* */ }
) {
Icon(Icons.Filled.Add, contentDescription = "")
}
}
)
}
}
)
}

HorizontalFloatingToolbar

@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Preview
@Composable
fun HorizontalFloatingToolbarWithFab() {
var expanded by rememberSaveable { mutableStateOf(true) }
val vibrantColors = FloatingToolbarDefaults.vibrantFloatingToolbarColors()
Scaffold { innerPadding ->
Box(
Modifier
.fillMaxSize()
.padding(innerPadding)
) {
Column(
Modifier.verticalScroll(rememberScrollState())
) {
Text(text = remember { LoremIpsum().values.first().take(800) })
}

HorizontalFloatingToolbar(
expanded = expanded,
floatingActionButton = {
FloatingToolbarDefaults.VibrantFloatingActionButton(
onClick = { /* */ },
) {
Icon(Icons.Filled.Add, contentDescription = "")
}
},
modifier =
Modifier.align(Alignment.BottomEnd),
colors = vibrantColors,
content = {
IconButton(onClick = { }) {
Icon(Icons.Filled.Person, contentDescription = "")
}
IconButton(onClick = { /* */ }) {
Icon(Icons.Filled.Edit, contentDescription = "")
}
},
)
}
}
}

FlexibleBottomAppBar

@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Preview
@Composable
fun FlexibleBottomAppBarEx() {
Scaffold(
bottomBar = {
FlexibleBottomAppBar(
horizontalArrangement = Arrangement.SpaceEvenly,
contentPadding = PaddingValues(horizontal = 0.dp),
scrollBehavior = scrollBehavior,
content = {
IconButton(onClick = { /* */ }) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = ""
)
}

FilledIconButton(
modifier = Modifier.width(56.dp),
onClick = { /* */ }
) {
Icon(Icons.Filled.Add, contentDescription = "")
}
IconButton(onClick = { /* */ }) {
Icon(Icons.Filled.ArrowForward, contentDescription = "")
}

}
)
},
content = { innerPadding ->
LazyColumn {
val list = (0..25).map { it.toString() }
items(count = list.size) {
Text(text = list[it])
}
}
}
)
}

--

--

Nav Singh
Nav Singh

Written by Nav Singh

Google Developer Expert for Android | Mobile Software Engineer at Manulife | Organizer at GDG Montreal

Responses (1)