What Material 3 Expressive actually changes in Compose
“Material You”, “Material 3”, “Material 3 Expressive” get used interchangeably, including by people shipping apps, and as a result plenty of apps advertising the third are doing the first. Dynamic colour arrived with Android 12 and has nothing to do with the 2025 revision. If an app’s expressive credentials are a wallpaper-derived accent colour, it is a Material 3 app with dynamic colour switched on.
I built a calendar on the expressive system. The colour part turned out to be the least of it, the motion part most of it, and two of the defaults are wrong for anything page-sized.
The motion scheme is the actual API
The change you can point at in code is MaterialTheme.motionScheme. It hands
you animation specs rather than leaving every component to invent its own
duration and easing:
@Composable
fun rememberSlideSpec(): FiniteAnimationSpec<IntOffset> =
MaterialTheme.motionScheme.defaultSpatialSpec()
The specs come in two families, and the split is worth internalising. Both are springs, which I had wrong for months:
- Spatial specs are for anything that changes shape or bounds: position, size, offset. They’re underdamped, so they overshoot the target and settle back.
- Effects specs are for anything that doesn’t, like opacity and colour. They’re critically damped, so they cannot overshoot at all.
In the tokens the split comes down to one number: in the standard scheme every spatial spring has a damping ratio of 0.9 and every effects spring has 1.0.
It sounds fussy until you break it. A shared-axis transition where the fade is
driven by the spatial spring shimmers at the settle, because an underdamped
spring drives alpha past 1.0 and oscillates back. Material says this outright:
effects springs exist for properties “where the property’s value should not be
overshot”, and gives the same example: a background alpha shouldn’t bounce
above 100%. Run the position on defaultSpatialSpec() and the opacity on
defaultEffectsSpec() and it stops.
Getting them out of a composable scope is a small annoyance worth mentioning:
AnimatedContent’s transitionSpec lambda is not composable, so you read the
specs in a remember-style helper first and capture them.
Three speeds, and I still picked the wrong one first
Each family comes in fast, default and slow. Material ties them to how much of the screen moves: fast for “small components like switches and buttons”, default for things that “partially cover the screen like a bottom sheet or nav drawer”, slow for “full screen animations”.
I reached for fastSpatialSpec() to page the calendar between months, which is
a full-screen content swap, and the settle read as a twitch rather than a
glide. By Material’s rule that was two steps off: a full-screen swap is the
slow case.
What I actually shipped is defaultSpatialSpec(), one step up rather than two.
Paging a calendar is a thing people do repeatedly and fast, and the slow spring
made it feel like the app was thinking about it. So this is taste overriding
the guidance, and I’m flagging it as taste. If you want the documented answer
for a full-screen transition, it is slow.
The expressive scheme is not automatically the right scheme
There are two motion schemes, standard and expressive, and the difference between them is entirely spatial. Their effects tokens are identical, damping 1.0 at the same three stiffnesses. What expressive changes is the bounce: spatial damping drops from 0.9 to 0.8, and its fast spring to 0.6.
For a calendar I went with standard. A month grid that bounces when it lands reads as sloppy, because a grid has hard edges that make you see exactly how far past the target it went. Note that 0.9 is still underdamped, so standard means less overshoot, not none. Material describes it as “a linear motion feel” rather than as a spring that never passes its mark.
Before you congratulate yourself on the restrained choice, as I briefly did:
standard() is already Compose’s default. You only get the expressive scheme
by opting into MaterialExpressiveTheme, so picking standard costs nothing and
amounts to declining an upgrade.
It is an alpha, and that is the real cost
I had a section here about the opt-in burden, and checking it for this post is
how I found out it was obsolete. MotionScheme, the six spec functions and
MaterialTheme.motionScheme carry no experimental annotation at all in the
version I ship. The only @ExperimentalMaterial3ExpressiveApi in that file
sits on a deprecated LocalMotionScheme I never touch.
My app still has seven @OptIn(ExperimentalMaterial3ExpressiveApi::class)
annotations. Every one of them is now a no-op I have been carrying around
because the compiler does not complain about opting in to something that no
longer needs it. They are on the list to delete.
The version is the real cost, and it hasn’t moved: this is
androidx.compose.material3:1.5.0-alpha26, because the expressive APIs live
only in the 1.5 alpha line. A dependency bump on an alpha is never routine:
signatures move, things get renamed, and occasionally a component you built a
screen around changes shape. What has actually helped is keeping the expressive
surface small: the specs are read in a handful of helpers and the screens call
those, so a rename costs a few files instead of a few dozen. Three of my
screens read motionScheme directly, and those are the ones I expect to pay
for.
Springs raise the stakes on reduce-motion
Spring physics is exactly the kind of motion that makes some people feel ill,
and the system setting for it is not optional to respect. Where a transition
checks it, it falls back either to a cross-fade on the effects spec or to
snap() (no animation at all), depending on whether a fade would smear the
thing being replaced.
Build this in from the first animation rather than adding it later.
Retrofitting a reduce-motion path across a finished app means finding every
animation you wrote, and you will not find them all. I know that because I
wrote the confident version of this paragraph first, then went looking: my
floating action button scales in, my search and detail overlays slide across
the full screen, and an import badge springs in on DampingRatioMediumBouncy,
none of them checking anything. Three that I found, in an app whose author was
about to tell you he had this handled.
What I did not do
The revision also brings a wider type scale and more shape variance. Calendula
leaves the type scale at Typography() and has never tuned it. It does let
you pick a typeface (three bundled faces plus a file you import), but that
swaps the family inside the default scale rather than reshaping it.
If you assume the default is the pre-expressive one, note that in
material3 1.5 the default Typography() already carries the emphasized
styles. Leaving it alone is not opting out of the wider scale. It is declining
to customise it, which is a smaller claim than “built on M3 Expressive
throughout” might suggest, and I would rather say so.
I’d apply the same caveat to the whole category. The system is worth adopting for the motion alone; a product claiming expressive design because it picked up your wallpaper colour is claiming something else entirely.
The app this came out of is Calendula. The source is on Codeberg, and what Glance does to all of this on the home screen is its own unhappy story.