If you've ever built an async action button in Angular Material — a "Save," "Download," or "Submit" button that needs a spinner while a request is in flight — you've probably hit the same annoying problem: swapping the button's content for a spinner shifts the button's width, causes layout jank, and usually means writing the same @if block over and over across your app.
Angular Material v22 finally fixes this at the source. It adds native progress support to matButton via a showProgress input and a progressIndicator content slot. And if you're using Mat Expressive, the M3 Expressive layer for Angular Material, this new slot is the perfect place to drop in its animated loading indicator — giving you a polished, spring-motion loading state with almost no code.
In this tutorial, we'll walk through both pieces: what Angular Material v22 added, and how to wire up Mat Expressive's loading indicator inside it.
The old way (and why it's annoying)
Before v22, a loading button usually looked something like this:
<button matButton="filled" [disabled]="loading()" (click)="save()">
@if (loading()) {
<mat-spinner diameter="20" />
} @else {
Save
}
</button>
This works, but it has a real UX problem: when the spinner replaces the label, the button's content width changes, so the button itself can resize or shift — especially noticeable in button rows or toolbars. Most teams end up hand-rolling a directive just to fix this width issue, which is exactly what the community has been doing for years with things like custom MatButtonLoadingDirectives.
What Angular Material v22 adds
Angular Material v22 introduces two additions to matButton:
-
showProgress— a boolean input that tells the button when to display a progress indicator -
progressIndicator— a content-projection slot for the actual spinner/indicator element
Here's the key difference from the old approach: the button's normal content stays in the DOM even while the progress indicator is shown. That means the button keeps the same width it had before — no layout jump, no resizing.
A minimal example using Angular Material's own spinner:
<button
matButton="filled"
[showProgress]="saving()"
(click)="save()"
>
Save
<mat-progress-spinner
progressIndicator
diameter="20"
mode="indeterminate"
></mat-progress-spinner>
</button>
When saving() is true, the spinner appears in place without disturbing the button's layout. When it's false, the button just shows "Save" as normal.
This is a small API surface, but it removes a genuinely common pain point — and it means the loading UI is now something Angular Material understands and manages, rather than something every app reinvents.
Dropping in Mat Expressive's loading indicator
This is where it gets interesting for anyone using Mat Expressive — the library that layers Material Design 3 Expressive styling, spring motion (via GSAP), and extra components on top of Angular Material, without forking or replacing it.
Because the new progressIndicator slot just wants any element marked with the progressIndicator attribute, Mat Expressive's loading indicator drops straight in — no custom directive, no extra wiring, no glue code:
<button
matExpButton
size="m"
matButton="outlined"
[showProgress]="showProgress()"
(click)="toggleShowProgress()"
[style.--mat-exp-loading-indicator-size]="'36px'"
>
Click to toggle progress
<mat-exp-loading-indicator
progressIndicator
[config]="config()"
[speed]="speed()"
[ariaLabel]="ariaLabel() ?? 'Loading'"
></mat-exp-loading-indicator>
</button>
That's the entire integration. A few things worth noting:
-
matExpButtonis the zero-config directive that applies Mat Expressive's M3 Expressive styling and spring motion to an existingmatButton— you don't need to swap components, just add the directive. -
mat-exp-loading-indicatoris Mat Expressive's own animated loading indicator. Because it's marked withprogressIndicator, Angular Material's button directive knows to show and hide it automatically based onshowProgress. - It's configurable via
[config]and[speed]inputs, and takes an[ariaLabel]for accessibility (defaulting to"Loading"if not provided). - Sizing is controlled via the
--mat-exp-loading-indicator-sizeCSS custom property, set directly on the host button in the example above. - The result respects
prefers-reduced-motionand is SSR-safe out of the box, since that's baked into Mat Expressive's animation layer. - It works identically in light and dark themes with no extra theming work.
Seeing it in action
Rather than describe the animation, it's easier to see it live. Mat Expressive has an interactive playground for this exact component where you can toggle the state and try different configurations directly in the browser:
👉 Loading Indicator Playground
Wrapping up
Angular Material v22's showProgress and progressIndicator slot solve a real, long-standing annoyance: loading buttons that don't jump around. And because the slot is just standard content projection, it composes cleanly with other libraries — like Mat Expressive — that want to plug richer, animated indicators into that same spot.
If you're already on Angular Material v22, upgrading a loading button takes minutes. If you're using Mat Expressive on top of it, you get a considerably nicer-looking result for the same amount of code.
Try it yourself:
- Mat Expressive docs: expressive.angular-material.dev
- Install:
ng add @ngm-dev/mat-exp - GitHub: github.com/Angular-Material-Dev/mat-exp

Top comments (0)