Something shifted quietly on July 1, 2026. Material3 1.5.0-alpha23 landed in the Jetpack release channel, and the release notes listed something I'd been waiting for: over a dozen Material Expressive components officially graduating from @ExperimentalMaterial3ExpressiveApi to stable. No big announcement, no I/O demo — just a release note update that changes what you can safely ship.
Material Expressive has been in flight since late 2025. Google's stated goal is to make Android apps feel less static — more reactive to gestures, more personalized, more alive in ways that Material Design 3 couldn't express. The design spec and the Compose implementation have been maturing in lockstep through the 1.5.0 alpha cycle, and alpha23 is the point where I'd call the core of it production-ready if you're willing to stay on the alpha track of a library.
What Material Expressive Actually Is
The short version: it's the next evolution of Material Design 3, built specifically for the era of foldables, large screens, and apps that need to feel distinct and branded rather than generic. Where Material You (M3) nailed adaptive theming and dynamic color, Material Expressive adds motion-forward components, more sculptural shapes, and richer interaction states.
In practice that means things like: a progress indicator that moves in a satisfying wave instead of a flat bar. Buttons and chips that morph their shape based on selection state. A floating toolbar that reveals additional actions without a separate bottom sheet. Group controls for toggle behavior that used to require hand-rolled composables. None of these are frivolous — they're components that, once you use them, make the old versions feel like placeholders.
The separation between Compose 1.x (the layout/foundation/runtime libraries) and Material3 1.5.0 (the component library) is important here. You don't need to bump your Compose BOM to pick up Material Expressive — you just need to update the compose-material3 dependency. The two versioned separately through the 1.5.0 alpha cycle, which is why this release doesn't show up in Compose 1.12 post titles.
The Expressive Theme: Adopting It Without Breaking Everything
The entry point to Material Expressive is materialExpressTheme paired with expressiveLightColorScheme(), both now stable in alpha23. These are drop-in replacements for MaterialTheme and lightColorScheme() that unlock the expressive defaults across all the new components:
// Before
MaterialTheme(
colorScheme = lightColorScheme(primary = yourBrandColor),
typography = yourTypography
) {
AppContent()
}
// After — expressive defaults, same color slot structure
MaterialExpressiveTheme(
colorScheme = expressiveLightColorScheme(primary = yourBrandColor),
typography = yourTypography
) {
AppContent()
}
The color scheme slots are identical to M3, so if your app already uses dynamic color or has a hand-crafted color scheme, the migration is mechanical — swap the wrapper and the scheme constructor, nothing else. What changes is the default shape system (rounded, more sculptural) and the motion spec used by components internally. Your custom components that don't use Material3 defaults are completely unaffected.
For Nodat, which uses a fairly opinionated design language on top of M3, my plan would be to adopt the theme at the root and then selectively override the places where the expressive defaults conflict with the app's visual identity. That's the same approach that works for any theme migration in Compose — start at the root, measure what breaks, fix the overrides.
WavyProgressIndicator: The Easiest Win
This is the component I'd adopt first in any production app. The existing LinearProgressIndicator gets the job done, but it's visually boring — a flat bar that fills in a straight line. WavyProgressIndicator replaces it with an animated wave that communicates progress in a way that feels inherently more dynamic, especially for media loading or file uploads where the user is actively waiting.
// Indeterminate loading
WavyProgressIndicator(
modifier = Modifier.fillMaxWidth()
)
// Determinate progress (e.g. upload, download)
WavyProgressIndicator(
progress = { uploadProgress },
modifier = Modifier.fillMaxWidth()
)
The API surface matches LinearProgressIndicator exactly — same progress lambda, same modifier parameter, same color slots through ProgressIndicatorDefaults. The implementation is pure Canvas drawing with no external dependencies, so it adds no meaningful overhead to your composable tree.
In Musist, I've been using a custom animated progress bar for track loading because the stock one looked flat against the app's dark theme. WavyProgressIndicator styled with the app's accent color would replace that entirely and lose roughly 80 lines of custom animation code. That's the best kind of migration: one where the platform does what you were already doing yourself, better.
ButtonGroup and ToggleButtons: Finally, Clean Segmented Controls
Before Material Expressive, building a proper segmented control in Compose was painful. You'd combine OutlinedButtons, manually clip their corners based on position, manage mutual exclusion with your own state, and hope it looked right on every screen density. There was no official API. Everyone had a slightly different approach, and they all had edge cases.
ButtonGroup with ToggleButton children is the official solution, and both are now stable:
var selectedMode by remember { mutableIntStateOf(0) }
ButtonGroup {
ToggleButton(
checked = selectedMode == 0,
onCheckedChange = { selectedMode = 0 }
) {
Icon(Icons.Default.ViewList, contentDescription = "List view")
}
ToggleButton(
checked = selectedMode == 1,
onCheckedChange = { selectedMode = 1 }
) {
Icon(Icons.Default.GridView, contentDescription = "Grid view")
}
ToggleButton(
checked = selectedMode == 2,
onCheckedChange = { selectedMode = 2 }
) {
Icon(Icons.Default.Map, contentDescription = "Map view")
}
}
The ButtonGroup handles corner rounding automatically — the leftmost button gets rounded-left corners, the rightmost gets rounded-right, middle buttons are square — and applies the right selection highlight to whichever ToggleButton has checked = true. It also enforces the visual spacing contract from the Material Expressive spec, which means all three options sit flush against each other with no gaps.
I'd put this straight into the bottom toolbar for Samachar's article view, where users switch between reading mode, text size controls, and sharing options. It's exactly the use case this was designed for.
FloatingToolbar: A Better Pattern for Multi-Action Surfaces
The classic FAB pattern works well for a single primary action. But what about screens with three or four related actions? The traditional answer is "use a bottom app bar with icons, or a speed dial FAB." Both have real UX problems — the bottom bar competes with navigation, and speed dials bury actions behind a tap that users have to discover. FloatingToolbar is a cleaner alternative.
The component renders as a pill-shaped floating container that can either show all its actions inline or collapse to a single FAB and expand on tap. The expansion animation is smooth — it's one of the places where Material Expressive's motion emphasis shows most clearly:
var expanded by remember { mutableStateOf(false) }
FloatingToolbar(
expanded = expanded,
floatingActionButton = {
FloatingToolbarDefaults.VibrantFloatingActionButton(
onClick = { expanded = !expanded }
) {
Icon(
imageVector = if (expanded) Icons.Default.Close else Icons.Default.Edit,
contentDescription = if (expanded) "Close" else "Edit"
)
}
}
) {
IconButton(onClick = { /* Bold */ }) {
Icon(Icons.Default.FormatBold, contentDescription = "Bold")
}
IconButton(onClick = { /* Italic */ }) {
Icon(Icons.Default.FormatItalic, contentDescription = "Italic")
}
IconButton(onClick = { /* Link */ }) {
Icon(Icons.Default.Link, contentDescription = "Insert link")
}
}
FloatingToolbarDefaults.VibrantFloatingActionButton is the expressive-styled FAB that goes with it — it uses a higher-contrast, more saturated color fill than the standard M3 FAB. You can substitute a plain FAB if you prefer the stock appearance.
The toolbar respects WindowInsets automatically, so positioning it above a navigation bar or system gesture zone is handled without manual padding. That alone removes a category of device-testing headaches.
Bottom Sheet State Unification
This one is less glamorous than the new components, but it'll affect most apps that use bottom sheets. Material3 1.5.0 introduces a single rememberBottomSheetState() that works with both ModalBottomSheet and BottomSheetScaffold, deprecating the separate rememberModalBottomSheetState() and rememberStandardBottomSheetState() functions that currently exist:
// Both of these are now deprecated
val modalState = rememberModalBottomSheetState()
val scaffoldState = rememberStandardBottomSheetState()
// Use this for both
val sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
confirmValueChange = { it != SheetValue.Hidden || canHide }
)
// Works with ModalBottomSheet
ModalBottomSheet(
onDismissRequest = { /* ... */ },
sheetState = sheetState
) {
SheetContent()
}
// Also works with BottomSheetScaffold
BottomSheetScaffold(
sheetContent = { SheetContent() },
sheetState = sheetState
) {
MainContent()
}
If your app uses both modal and persistent bottom sheets, you no longer need to track which state type belongs to which component. The unified state object carries the same properties — currentValue, targetValue, isVisible — so migration is find-and-replace plus removing the modal/standard distinction from your state declarations.
Migration note: The old functions are deprecated but not yet removed. You have time to migrate, and the IDE will show deprecation warnings pointing at the unified API. I'd do this migration alongside any other Material3 1.5.0 work rather than as a separate pass.
Shape Morphing Chips: Selection That Feels Alive
New overloads for FilterChip, ElevatedFilterChip, and InputChip add shape morphing to the selection state. When a chip transitions from unselected to selected, its shape shifts — rounded corners change, the aspect ratio subtly adjusts — rather than just swapping a color. The effect is subtle but it makes filter chips feel genuinely interactive rather than just toggling between two static appearances.
FilterChip(
selected = isSelected,
onClick = { isSelected = !isSelected },
label = { Text("Architecture") },
leadingIcon = if (isSelected) {
{ Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(18.dp)) }
} else null
// shape morphing is applied via the new overloads — check FilterChipDefaults
// for the expressive shape parameters
)
The shape morphing overloads follow the same composable signature as the existing chips, so these are additive — you opt into the expressive shape behavior per-callsite rather than changing it globally. That's the right design: apps that need a consistent, uniform chip appearance everywhere can ignore the overloads; apps that want the expressive defaults can adopt them incrementally.
What's Still Experimental
Not yet stable in alpha23: The Material Expressive TimePicker variant, the Scrim and StaticSheet standalone components, and some of the new SearchBar expanded states are still marked experimental as of 1.5.0-alpha23. Don't build load-bearing production features on these — they're present but API shape may still change. The stable promotions in alpha23 cover the main action and navigation component set; form components are following behind.
Similarly, SplitButton (primary action + secondary dropdown in a single pill shape) is now stable, as are the expressive button variants and expressive menu APIs — but the full expressive form system (text fields with TextFieldLabelPosition.Inside and the new roundedShape defaults) is still settling. The rule for now: if it's not listed as a stable promotion in the 1.5.0-alpha23 release notes, treat it as experimental.
My Production Take: What to Adopt and When
The 1.5.0 alpha track is not stable — the library version says alpha, and library alphas in Jetpack can and do have breaking API changes between releases. That's the honest caveat. But "not stable" on an alpha track doesn't mean "not usable," and this matters for how you plan.
Here's how I'd approach it for a real app:
- Adopt immediately, low risk:
WavyProgressIndicator— it's a drop-in, the API is clean, and the worst that happens if it changes is a one-line fix.ButtonGroup+ToggleButtonsis similar — small surface area, clear stable signal. - Adopt with a migration plan:
materialExpressThemeat the app root changes shape and motion defaults globally. Do this in a feature branch, audit your screens for regressions, then merge. It's a one-time cost and the visual payoff is significant. - Prototype now, ship at 1.5.0-stable:
FloatingToolbaris feature-complete and stable-marked, but I'd wait for the library itself to hit stable before putting it in a screen that 100K+ users see daily. Same forSplitButton. - Wait: Anything still in
@ExperimentalMaterial3ExpressiveApi. The experimental annotation exists for a reason.
One thing I'd track closely: the 1.5.0 stable release date. The alpha cycle has been moving steadily — alpha01 through alpha23 in roughly 6 months — so a stable release in late 2026 seems plausible. At that point, everything that graduated to stable in the alphas becomes a straightforward library version bump with no code changes on your side.
The bigger picture here is what Material Expressive represents for Android as a platform. Google has spent years watching iOS develop a more polished, motion-forward UI vocabulary and fighting the perception that Android apps feel generic. Material Expressive is the structural answer — not just new widgets, but a design language that makes it possible to build UI that feels custom and alive without rolling everything from scratch. For apps like Musist that live and die on their visual feel, that's genuinely meaningful.
The components are there, they're stable enough to act on, and the migration story from M3 is as smooth as any Jetpack library upgrade I've seen. July 1, 2026 is a quiet date that I think will look more significant in retrospect.
No comments yet. Be the first to leave one!