Design 9 min read

Compose Material3 1.5.0-alpha25: The Expressive API Cleans House

Material3 1.5.0-alpha25 landed on July 29 and it's the messiest release in a good way. The team is clearly hardening the Expressive component suite ahead of a stable milestone — and that means a round of renames, deprecations, and API surface cleanup that will affect anyone who's been tracking the alpha series. Six things got renamed or restructured; two of them are binary-compatible, two are straight deprecations with clear replacements, and one is a deletion with no migration path.

I'm bundling in the alpha24 changes (July 15) as well, because they never got a proper write-up and one of them — a brand new material3-ripple artifact — is more significant than the version bump implies. If you're on a Material3 alpha already, this post is the migration checklist you're going to want before your next upgrade.

6+
API renames and deprecations in alpha25
Stable
BasicAlertDialog graduates out of Experimental
New
material3-ripple artifact with inset focus rings

trailingIcon Is Now trailingContent in DropdownMenuItem

The rename that's most likely to show up in compiler errors: trailingIcon has been renamed to trailingContent across all three DropdownMenuItem overloads — the standard one plus the shape and checked/selected variants. The MenuItemColors type also picks up the same rename in its new constructor overload.

The good news: this is binary-compatible. The old parameter name still exists at the bytecode level, so existing compiled code keeps running. But source won't compile cleanly once you take the alpha25 artifact — the compiler will warn about the deprecated overload. The fix is purely mechanical:

// Before
DropdownMenuItem(
    text = { Text("Export") },
    trailingIcon = { Icon(Icons.Default.Share, contentDescription = null) },
    onClick = { /* ... */ }
)

// After
DropdownMenuItem(
    text = { Text("Export") },
    trailingContent = { Icon(Icons.Default.Share, contentDescription = null) },
    onClick = { /* ... */ }
)

The rename signals intent: the slot is more general than just an icon. You could put a badge, a count chip, or a toggle in there, and "trailingContent" conveys that better. In Nodat, I have a couple of dropdown menus where the trailing slot holds a count badge rather than an icon — the new name makes more semantic sense for that use case.

TonalToggleButton Is Now FilledTonalToggleButton

This one's a straight rename with no migration path other than updating callsites. TonalToggleButton is deprecated in alpha25 and the replacement is FilledTonalToggleButton, which matches the naming convention already used for FilledTonalButton in the non-toggle family. The parameters are identical.

Two related changes ship alongside the rename: OutlinedToggleButton now animates its border stroke when toggling state, and ToggleButtonDefaults.shapes is deprecated in favor of a new ToggleButtonDefaults.shapesFor(checked) function. The shapesFor variant is cleaner — it takes the current checked state and returns the right Shape rather than making you pull the pair apart yourself.

// Before
TonalToggleButton(
    checked = isEnabled,
    onCheckedChange = { isEnabled = it }
) {
    Text("Auto")
}

// After
FilledTonalToggleButton(
    checked = isEnabled,
    onCheckedChange = { isEnabled = it }
) {
    Text("Auto")
}

// shapes DSL: before
val shape = if (checked) ToggleButtonDefaults.shapes.pressedShape
            else ToggleButtonDefaults.shapes.shape

// after
val shape = ToggleButtonDefaults.shapesFor(checked)

SplitButtonLayout Is Deprecated — Use SplitButton

This is the deprecation most likely to catch teams by surprise. SplitButtonLayout was introduced relatively recently as part of the Expressive push, and it's already being replaced by a higher-level SplitButton composable. The team decided the layout primitive was too low-level to be the recommended entry point — most callsites were manually constructing the same structure anyway.

SplitButton takes a leading action content and a trailing dropdown trigger and wires up the interaction model for you. If you were using SplitButtonLayout with custom leading/trailing slots you'll need to evaluate whether SplitButton's slot contract covers your use case, but for the common case — a primary button paired with a dropdown arrow — the migration is straightforward.

Check your dependency graph: if any library you depend on has public APIs that expose SplitButtonLayout, you'll need to wait for those to update before upgrading. The deprecation is source-only in alpha25, but the plan is removal in a future release.

ButtonGroupScope Is Now a sealed interface

ButtonGroupScope — the receiver for child composables inside a ButtonGroup — was previously an open interface. Alpha25 changes it to a sealed interface. If you've been implementing it directly (to, say, provide a test double or a custom scope), that won't compile anymore. The sealed nature is intentional: the library owns all implementations, and the API contract is through the composables it provides rather than through external conformances.

In practice, most apps don't implement ButtonGroupScope directly — you consume it implicitly in the trailing lambda. But if you've been doing anything creative with it in custom layout wrappers or test code, this is the change to watch for.

The Modifier.animateWidth function also got restructured in the same release: it's now two overloads rather than one, letting you omit the compressionLimit parameter entirely, and the parameter type changed from PaddingValues to Dp — so calls that pass a PaddingValues to compressionLimit will need updating too.

SliderState Drops Its DraggableState Conformance

This is the change most likely to silently affect custom slider implementations. SliderState no longer publicly implements DraggableState. If you were casting a SliderState to DraggableState to wire it into a custom drag gesture or a combined gesture handler, that cast is now a runtime crash.

The right fix depends on what you were doing with it. If you were tracking drag deltas to implement a secondary gesture on top of the slider, SliderState still exposes the value and drag callbacks you need — you just have to work through its own API surface rather than the generic DraggableState interface.

The Saver API also changes: the 2-parameter SliderState.Saver and RangeSliderState.Saver are deprecated in favor of new overloads that take steps explicitly. This makes the restored state more predictable when the number of steps is non-default — a subtle correctness fix for anyone saving/restoring slider state across process death.

// Before (deprecated)
val saver = SliderState.Saver

// After (explicit steps)
val saver = SliderState.Saver(steps = 0)

ComponentOverride APIs Are Gone

The ComponentOverride APIs introduced in an earlier alpha are removed in alpha25, with no deprecation period. This was an experimental mechanism for overriding component implementations at a theme level, and the team decided the design wasn't right before it could graduate. If you were using it — and it was genuinely obscure, so most people weren't — you'll need to remove those references entirely. There's no announced replacement yet.

BasicAlertDialog graduated from Experimental to stable in alpha25, removing the @ExperimentalMaterial3Api requirement. If you added @OptIn solely for that composable, the annotation is now unnecessary (though harmless).

What Alpha24 Actually Shipped (And Why material3-ripple Matters)

Alpha24 landed July 15 and contained two things worth covering properly. The first is the new material3-ripple artifact. The second is a new scroll variant for the expressive TimePicker.

The material3-ripple artifact

The standard Material3 ripple uses an opacity-based approach to indicate focus — when a component receives keyboard or accessibility focus, the ripple overlay changes opacity. The new material3-ripple library switches this to an inset focus ring model: focus is shown as a separate ring drawn inside the component bounds, not as an opacity change on the ripple itself.

This matters for a few reasons. Inset focus rings are more visible on dark backgrounds (a known issue with the opacity approach on surfaces close to black). They're also the pattern used in Material Design's own web implementations, so there's now design consistency across platforms. And they're more accessible — the ring is a distinct visual affordance rather than a subtle opacity shift that users with certain visual impairments can miss.

// If you're already on Material3, no action needed —
// the new ripple behavior integrates automatically via the theme.

// If you want the inset ring behavior WITHOUT depending on material3:
dependencies {
    implementation("androidx.compose.material3:material3-ripple:1.5.0-alpha24")
}

// Then use MaterialRippleTheme or the ripple() function from this artifact
// to apply inset focus rings to your own design system components.

For most apps that already depend on material3, this is a free improvement — the theme automatically picks up the new ripple. The separate material3-ripple artifact exists for teams running their own design system on top of Compose UI primitives who want inset focus rings without taking the full Material3 dependency.

Expressive TimePicker: Scroll Variant

The Expressive design system's TimePicker now ships with a scroll variant alongside the existing dial and input variants. The scroll variant presents hours and minutes as independently scrollable columns — the pattern most users recognize from native iOS time pickers and from clock pickers in many modern apps.

In Musist, where I have a custom alarm-style timer that I built from scratch because nothing in Material3 previously covered it cleanly, this is the component I would have used. The scroll variant handles edge cases (looping, snap-to-minute, focus and accessibility) that are non-trivial to implement correctly from scratch.

It's still marked experimental in alpha24, but the design is stable enough to prototype with. If you have a time-input screen in your app, it's worth a look.

The SearchBar Stable Graduation

Also in alpha24: the slot-based SearchBar API and SearchBarState are promoted to stable, and the older SearchBar/DockedSearchBar overloads that took expanded/onExpandedChange parameters are deprecated. If you're still on the expanded-based API, the migration is a state hoisting refactor — move the expanded state into a SearchBarState obtained via rememberSearchBarState() and let the composable manage the transitions internally.

// Before (deprecated)
var expanded by remember { mutableStateOf(false) }
SearchBar(
    query = query,
    onQueryChange = { query = it },
    onSearch = { /* ... */ },
    active = expanded,
    onActiveChange = { expanded = it }
) {
    /* suggestions */
}

// After
val searchBarState = rememberSearchBarState()
SearchBar(
    inputField = {
        SearchBarDefaults.InputField(
            searchBarState = searchBarState,
            query = query,
            onQueryChange = { query = it },
            onSearch = { /* ... */ },
        )
    },
    state = searchBarState,
) {
    /* suggestions */
}

The slot-based API is more verbose up front but pays off when you need to customize the input field — add a leading icon, swap in a custom chip, or run query transformations before they hit the search handler. The old API collapsed all of that into parameters that quickly became unwieldy.

What to Actually Do With All of This

If you're on a pinned stable Material3 version and none of these changes affect your build today, that's fine. But if you're tracking the alpha series — maybe because you want Expressive components before they hit stable — here's how I'd approach the upgrade to alpha25:

The pattern across alpha24 and alpha25 taken together is clear: Material3's Expressive APIs are converging. The renames and deprecations aren't churn for its own sake — they're the team standardizing naming conventions (trailingContent over icon-specific names, FilledTonal prefix consistency) and removing high-level layout primitives in favor of proper component APIs. A stable 1.5.0 release that ships these conventions as the baseline is within sight. The question for your team is whether you want to absorb the churn now, before the GA flood of tutorials and code generators cements the old names, or wait and do a bigger migration in one shot when stable arrives.

I'd lean toward absorbing the renames now if you're already on any 1.5.0 alpha. They're mostly find-and-replace, and getting aligned with the final naming before stable means your code won't read as legacy the moment 1.5.0 ships.

Comments 0

No comments yet. Be the first to leave one!

Leave a comment