Migrating to Expressive

From classic Material 3 APIs to their Expressive replacements, one component at a time.

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.