Migrating to Expressive
From classic Material 3 APIs to their Expressive replacements, one component at a time.
- Interactive page: https://m3e.ljzapps.app/#foundations/migration
Expressive does not replace Material 3, it extends it. The classic APIs keep working, so you can migrate one screen or one component at a time. Most Expressive components are stable by now (button groups, split buttons, toolbars, the new navigation bar and rail, menus, chips). Some still need @OptIn(ExperimentalMaterial3ExpressiveApi::class), among them LoadingIndicator, the flexible app bars, the new list items and VerticalSlider. Everything ships in the Material3 alpha line, so pin the version.
Theme
MaterialTheme → MaterialExpressiveTheme
Turns on the expressive motion scheme. Pass your color scheme yourself: without one it falls back to expressiveLightColorScheme(), so dark mode does not switch on its own. Nested MaterialExpressiveThemes inherit the values of the outer one.
Before:
MaterialTheme(colorScheme = scheme) { App() }
After:
MaterialExpressiveTheme(
colorScheme = scheme,
motionScheme = MotionScheme.expressive(),
) { App() }
Buttons that react to touch
Button(shape = ...), IconButton(shape = ...) → Button(shapes = ...), IconButton(shapes = ...)
The press morph lives in a separate overload that takes ButtonShapes or IconButtonShapes. The old overload keeps working, it just never morphs.
Before:
Button(onClick = onSave) { Text("Save") }
After:
Button(onClick = onSave, shapes = ButtonDefaults.shapes()) { Text("Save") }
Segmented buttons
SingleChoiceSegmentedButtonRow + SegmentedButton → Row + ToggleButton with connected shapes
The Expressive guidelines use connected button groups for selection. There is no dedicated composable: a Row with ConnectedSpaceBetween and ToggleButtons with the leading, middle and trailing shapes. Selection logic and the radio button role move into your code.
Before:
SingleChoiceSegmentedButtonRow {
options.forEachIndexed { index, label ->
SegmentedButton(
selected = index == selected,
onClick = { selected = index },
shape = SegmentedButtonDefaults.itemShape(index, options.size),
) { Text(label) }
}
}
After:
Row(horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween)) {
options.forEachIndexed { index, label ->
ToggleButton(
checked = index == selected,
onCheckedChange = { selected = index },
shapes = when (index) {
0 -> ButtonGroupDefaults.connectedLeadingButtonShapes()
options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes()
else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
},
modifier = Modifier.weight(1f).semantics { role = Role.RadioButton },
) { Text(label) }
}
}
Menus
DropdownMenu + DropdownMenuItem → DropdownMenuPopup + DropdownMenuGroup + DropdownMenuItem with shapes
DropdownMenu keeps the baseline look. The expressive menu splits sections into groups with a MenuDefaults.GroupSpacing gap, and items get a shape from MenuDefaults.itemShape(index, count). Choices use the selected or checked overloads instead of a custom trailing check.
Before:
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(text = { Text("Copy") }, onClick = onCopy)
DropdownMenuItem(text = { Text("Paste") }, onClick = onPaste)
}
After:
DropdownMenuPopup(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuGroup(shapes = MenuDefaults.groupShape(0, 1)) {
DropdownMenuItem(onClick = onCopy, text = { Text("Copy") }, shape = MenuDefaults.itemShape(0, 2).shape)
DropdownMenuItem(onClick = onPaste, text = { Text("Paste") }, shape = MenuDefaults.itemShape(1, 2).shape)
}
}
Filter and input chips
FilterChip(shape = ...), InputChip(shape = ...) → FilterChip(shapes = FilterChipDefaults.shapes()), InputChip(shapes = InputChipDefaults.shapes())
The shapes overload morphs the corners on select and press and switches to tonal colors. Migrate a whole row at once so the chips match. AssistChip and SuggestionChip have no such overload.
Before:
FilterChip(selected, onClick = { selected = !selected }, label = { Text("Vegan") })
After:
FilterChip(selected, onClick = { selected = !selected }, label = { Text("Vegan") }, shapes = FilterChipDefaults.shapes())
Short waits
CircularProgressIndicator() → LoadingIndicator()
For indeterminate waits that should feel lively. Keep a progress indicator when the user needs to see how much is left.
Before:
CircularProgressIndicator()
After:
LoadingIndicator()
Progress
LinearProgressIndicator, CircularProgressIndicator → LinearWavyProgressIndicator, CircularWavyProgressIndicator
Same progress lambda. The default wave is flat below 10 and above 95 percent progress.
Before:
LinearProgressIndicator(progress = { downloaded })
After:
LinearWavyProgressIndicator(progress = { downloaded })
Navigation bar
NavigationBar + NavigationBarItem → ShortNavigationBar + ShortNavigationBarItem
Rename both. On medium widths, use iconPosition = NavigationItemIconPosition.Start and ShortNavigationBarArrangement.Centered.
Before:
NavigationBar {
NavigationBarItem(selected, onClick, icon = { Icon(icon, null) }, label = { Text(label) })
}
After:
ShortNavigationBar {
ShortNavigationBarItem(selected, onClick, icon = { Icon(icon, null) }, label = { Text(label) })
}
Navigation rail
NavigationRail + NavigationRailItem → WideNavigationRail + WideNavigationRailItem
The rail can expand. Every item needs railExpanded, which you compute from the rail state, for example state.targetValue == WideNavigationRailValue.Expanded. ModalWideNavigationRail covers the modal case.
Collapsing top app bars
MediumTopAppBar, LargeTopAppBar → MediumFlexibleTopAppBar, LargeFlexibleTopAppBar
Adds a subtitle slot and titleHorizontalAlignment. Collapsing still needs a scroll behavior plus Modifier.nestedScroll on the scrolling container.
Before:
LargeTopAppBar(title = { Text("Inbox") }, scrollBehavior = scrollBehavior)
After:
LargeFlexibleTopAppBar(
title = { Text("Inbox") },
subtitle = { Text("12 unread") },
scrollBehavior = scrollBehavior,
)
Speed dial
A FAB plus your own animated column of small FABs → FloatingActionButtonMenu + ToggleFloatingActionButton
The menu animates the items for you. Closing on item click, on back and on outside taps is still your code.
Bottom app bar
BottomAppBar → FlexibleBottomAppBar or HorizontalFloatingToolbar
Expressive adds a docked toolbar (FlexibleBottomAppBar) and floating toolbars that can scroll away with FloatingToolbarDefaults.exitAlwaysScrollBehavior.
Emphasized type
MaterialTheme.typography.titleLarge.copy(fontWeight = ...) → MaterialTheme.typography.titleLargeEmphasized
Every style of the type scale has an Emphasized twin. Use it for headlines and selected states instead of changing the weight by hand.